From a22815e51eba99d77fc9643b81435bdf1c4fb123 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 11 Nov 2025 14:34:57 +0100 Subject: [PATCH 01/50] 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 00000000..00daa406 --- /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 00000000..df74392f --- /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 a27b02fb..0739f4ad 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 zcmeHQYjhmNm9EhP1Q;woWD78O@FN)P_yqp?t7_EK z8cEJ!|0KC(S66*?t8U%;s=BMyQ{DB$|9$RzLWmrP5T_%|6`~Nq)x9`OL)tWiaGmf! zcH(djJUeE98DIvO0cL<1Ug;q^9DZfXFqJEtu{;d~^MFHmByH(|6V6hL6t*NZyIeV8Xh!YV2g z^2~=2GLECy8&4#g;uf4~-}dQJ^f{G7V_l#?>7zE2llr`^Mks2;Oq{9jfYPU%ps`t) z4&&^LG~Mj?wi$`mu&>P+*WP9Hm<29^hb-hYmIx1IbVP+-Z!(^U1;Z_AoN3=TXGp1Q5pzvb7dejRxpcw>Gmc%qT}296{YVcr9k6}^(DjG zg5iKS7;cUrlij{yrEil`K-kWi)=eReWAr)nRFpQ9S|m;tSN$Pun9d9G>OmOFw?y<9 zppp~mbCZyqQ5@PuTVKJ7r0I zUJ|zH(@dBdmA+t`uf?FQu5C_EnaZYbrP8PSm+&d*3vTtrObhJxtyTK8eS~fLl;tu| z`~`doUsio}N}uiz!jtPutIDdcPwCU`B|Movx(?8}>Gf8vulZtS#rg(QEhaq`YPJ>b zJxiw3a8yImOgJ0IweZA?H9Zt;sx}h7(E3_WGS=+#8*Xz(Ssv(s1bH!BI!)C$ECF7E z@SsJ(iB-@ETyU8XjgaTSQyuNV%McD+A;b`_V`NqxJh?*UDKA|X&x4=Htws3@vIx&d z8rAdmqboidDO_1~$9GqM{hi9X@FJv*C@bYGzx?-adiz6kzqTC{fKGMGl zUh5arqF~1%Ced&5pG|e?3R47a!yo&)ZlYn~u`dSH3anwt?#6@81u-LDNVF{|9Q$uzF=2nxp11qV?u zAq%3pYgU;Bx2S?2;VS3u-c=_H4x!+xbisotc(qw@O&)^hPzS0s<*4#0S<)>_4x{Ah z>5_*~(jiO!a1KhA)?ZteD*EFrY3!}Cq)V1OfxJIQCYbgp1-q|5;@(|-p!9&O?j==d z9Tn<%K^EE-McGu*lO*eTflNphJtP6OBvo{e1jI+G=qnOXnc=sI)^6E1-R=UjkGk3m zWRErdoWe>3T0I0_OuscC>5uQp3$Yd z+KWYU1%^_-G`!FpGo+`zVEES%+a`O zFgfo7@d51J(Vr?RHw~&B7#pc{BA6;3rwV&22QZx0n(fZN&@>c@)g$h!4kin6s+f}7 zU!fCo^UK2qicFthA%uCpc72F*Wd}~tB*p|zRcggjQrh)O;pUf{;g(OIe+OAUael|7 z`JEa1soxXlrmNZyy(W0gRX}x^@$SkYjO=RjY^fYZ=PaP;?j1wJ|3X>1UU6=IdHQ^5 zPloL-GC9-UOnLD*dDn*)`-q1f!|txvicNB>O@qvYLNkH>B9+yr7N3UhPc1Gmk7t_4 zY3A`9^H^veXPQSEpQ*)j%wsW*Rl6@ZilEe!THH}6L@!~V@cO|d?T9j4fQ6#Il?cOm8t7+ZsWLo~P+e6G5o zs=`E{U$CrxSPQ&zIwMve*#>>4U4fvV?kaG*aYHcC zTHAE9;ZMX%nj^6<8XfTxKW;k`B_bFOCd>onxgi)xw3f6Q!IoBt!p1gb3dt&rxIY$* zqAexC2sHvXb0yJ`FN{WolWk2#jB>3fon!F&a6H%&HUdaCT8vnUZo}$|F?Cd|F4%5_ z)#8dV3ScwT*ro7Iw|c$l`>ktsh|B@0V0u`A3iBR)9!21+Auhav zo;A4;lGo3PC~kF_#BeX_z&(#cC#NGifkS418DIvO0cL<1U2axaCv2p@MfLA;K9%ojrI%#%5X`j@2ROa1;8mO4t>oWQ<=?i+DVhE@)^=a6 z@@ac_D0~O{f$DR@liQ{Ar2F9_nXZomHe9?wN)D>_y{W>VsqlRjo>1Y(D$G;$pQFNJ z6)sTWZ&g^S#@Q7rd_$!-D!f7AX$tH9)Arg!Jr1eON5Jf+cs z##!a2%(+2`87^;a*>&aK@~Tpg$GgZ|Z}IwDw|HBUzE}X6usSqgzR=)p!kTxyfY3W< zTmvf7s~ccUJ{jpPUsq1+sC{89xuACg%4s!!z0qvMj4;+|boPC$jCKs>6!)6L9<`G0p#{*ZNpvtRiecexFkG6% zk`39qK)r#5B;k#|P||GCp>)&k_5 ztb-_P4lL3k*IP7j`pT7!PN#{4=)l93a~yK*0VO@AuztSuy2ARo(nkvG=SkD?pohxq z=Sa&H*3XY>6xPp;5`?ibS3g(yM}>uY9`kPs>*q5mh4u59i?OJW%IoJBHNdn=Oh1R( zs<3|U)Tgk1&h$E!#|kXHUK`C3)4`2?p0ZeB{rsc~7|o;CB}{h%j=-==x>g&$-G=X% zn5r?|0US{t|D}6T;w;KbP=KHD^znWe^Kx$fIVY6`X|*P1Ke6j9a-RC*SidhW=W zrp&8wMv9!cvdoi{%g*=1~hMCy~>F$O*-2VdaGKjMbW(@ve;R zeyP3|cYxXG&{-FG*VlL&N^8n1y%>M)+6vF9X5Uy^zrM7rs&c9Vb@jD%c!kH)FsWiW zcTZxj*|7DfbbB=<6_4QFayj0Gr$Zm1Iz$3 zzzi@0%m6dM3@`)C05iZ0Fayj0Gr$Zm1Iz$3zzi@0%m6dM3@`)C05iZ0Fayj0Gr$Zm z1Iz$3zzi@0%m6dM3@`)C05iZ0Fayj$CIhtpANG#HtD^E{TY8^!EjAaj?*G?{w^Hr< zucfuP(lHrPm|hemk58+cZmRwN_NslBey848C5%^2$K1I6^(No{?=JOWwsxg~uvMSl z|Bv>~Ave|je|hTraPnnwyjTlt)rU82J55FEp!X5en-tpl>r?s$Rer+6SK4LQ*M|KB zXuwCh+ z_teP|w(F1*d8B>G?gSxn|21!UE#99rB`zr!6NJ>q^VsZWB=aqUzU$zZB| z4xP8le08c6VJmNc>OO~2r7x=V5w_~XwJKXbOt{bCkkUurTO~)>st-xo^i8!z6tj^98?v$RH(52ZioZPmKR(?)hlA=ny$A;^ijRCBd*cD0cu9yx)R6;E zW25n9Ayy+Cc@-XmjBX1whxCmJ@;dKi`y6(n{ABwaKK=a{u6^CPy10AQz^u7%+;oEK zbizv$Q7iU3q`1j({@r<(B5oa^bl=bjPd1Z1yzij_7UaH)YY}Q*#9!FHhjkdAG!FE> zhcsTv(O9GO6K`NEQ5q1R(xtlc4u29U+;{TU8T(F_=j~a7PX!fR;2JG(xJLWCE*$B9 z#Bq3dg&6JYI(Ouu+;c}B?kXO6B)52^zw2*CUPRhZZqA{uY(uOW`jdsov`!VikUjjUgJo})}d4_9r$r)nQw4ul~ zDjZ*Uhw3Sybcc9~@;gzFa60x}h;lFHo;R{Ybk+~wCZ6qw?C31yBRkHP_V4pM4j(&T zpdIIpkbT3`#i;nLYxLo+O(Qp={K?!49xtN$r^7y!9a<_z_x(y7J~o%yJ8uNs_g<3k z8odNu?8`lW#4MY;X{1_}JCK)j_<^M}J(M z$>-2Q#gXE7=wcgQYQtB%MxTK`>IeMc04~uV;3T6z`W$AA%sflkyI-|!=t?mi}${zIPmVjb3HeRkknnva+jO9L}&E{hBJDK!*NH%Ocy6FW?vmey$ey_ za4ybi;-ysV!S|og*0K6w|B1rChb(9QJ7)YoOZUT~6VAOM6rP z-6`aQQ^;SLLVj=x`5P06n3+T;aF ze)%k!&#=j{51zTH)%8mLimGRs!e^`S4z!Evb4oR$NVi9YjjEgy3wNiE$#6j7c{3zl zqQc8n=u%;o3OA`Rpu(sMx2y0@72c@q`=$zCRpHwzJgV$Eq43|S@ER4aSD|jdZucKm zx!aXIUxk^i8bkVf3d9j{O8fPaTdVk&X#DX==lHa&DW{q*Yhg}g2ABb6fEi#0m;q*h z8DIvO0cL<1UGGw$F;e@zF z^jgJkBiPcK$V{sXwi}^!kyxA0Cb%IONVJOH*)}_BJhe?X8~%id?ahO7vjx?a4W(nP zQ1Z^=UdX+XrknjVm?kl~9PWUHAY zW+Z&Uu;?vbUyIHj)qKp!?C|QlqCMo-o=f zjAmall&CPa24Na99n8T9s_|4sS_mD=u~F0P3x-H=BnMq+W!aOA8nM+R8cK^Ky`@PE zD%rX~y@9b7-slS@&B}(;S>u!kbVb5cFFA)C1>!scd7}4@92^heD01|us7CjN(nPt9 z5KIu~SjSD)9J5K|-nj#(FIyIxH;({Z2Na)!Z&B<$OX9~Ac2Af1b%hfOf244q!qf5L z3@Sg3KGFcUT;U}O*CA8f3_?PmZV#1!|NnYmv5CAt(NY~HoV(bzE9$G`<}PqAKCDG zHvD^u)Ar?}Wk^cnxi-AmhF9D0MjLLGINcul?#Q|DbUL07Pv^}A@QdJ=!LNW{34byC z68KBtFNI$U|5^Cu@YLt4;7?T-vI?ijoX@hJ(c>qK?AgXHv*WI>V+pnkYo@?*MV(Q?$!0M@?xwLBdg44|V`{=Y0mByxMf{VQZV=4&ykBY|;sC@xl4jG09hTE=6^ zHo8gGecKj{w)vt_BY#mVNbt?+oW4Hcbz?1ZeZ#SQsnSuL73 zme#K?Evu@WT=ybxU43nxw`#qoVN%8Nj%kwm%{%V(sB{7gmwTz>)U|lhd;xhYDXE$? zr|JptWS)G{Sa2)d=}PzUFQyIRR*$9-Olzi_jWo}X#KfUtf`0Sl)w{QFE%zuAi-tk$f zk)CDu|L_~nuD Date: Tue, 11 Nov 2025 15:24:56 +0100 Subject: [PATCH 02/50] 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 868d67f8..2c199530 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 0739f4ad..d9a7360d 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 03/50] 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 00daa406..00000000 --- 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 df74392f..00000000 --- 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 zcmeHQYjhmNm9EhP1Q;woWD78O@FN)P_yqp?t7_EK z8cEJ!|0KC(S66*?t8U%;s=BMyQ{DB$|9$RzLWmrP5T_%|6`~Nq)x9`OL)tWiaGmf! zcH(djJUeE98DIvO0cL<1Ug;q^9DZfXFqJEtu{;d~^MFHmByH(|6V6hL6t*NZyIeV8Xh!YV2g z^2~=2GLECy8&4#g;uf4~-}dQJ^f{G7V_l#?>7zE2llr`^Mks2;Oq{9jfYPU%ps`t) z4&&^LG~Mj?wi$`mu&>P+*WP9Hm<29^hb-hYmIx1IbVP+-Z!(^U1;Z_AoN3=TXGp1Q5pzvb7dejRxpcw>Gmc%qT}296{YVcr9k6}^(DjG zg5iKS7;cUrlij{yrEil`K-kWi)=eReWAr)nRFpQ9S|m;tSN$Pun9d9G>OmOFw?y<9 zppp~mbCZyqQ5@PuTVKJ7r0I zUJ|zH(@dBdmA+t`uf?FQu5C_EnaZYbrP8PSm+&d*3vTtrObhJxtyTK8eS~fLl;tu| z`~`doUsio}N}uiz!jtPutIDdcPwCU`B|Movx(?8}>Gf8vulZtS#rg(QEhaq`YPJ>b zJxiw3a8yImOgJ0IweZA?H9Zt;sx}h7(E3_WGS=+#8*Xz(Ssv(s1bH!BI!)C$ECF7E z@SsJ(iB-@ETyU8XjgaTSQyuNV%McD+A;b`_V`NqxJh?*UDKA|X&x4=Htws3@vIx&d z8rAdmqboidDO_1~$9GqM{hi9X@FJv*C@bYGzx?-adiz6kzqTC{fKGMGl zUh5arqF~1%Ced&5pG|e?3R47a!yo&)ZlYn~u`dSH3anwt?#6@81u-LDNVF{|9Q$uzF=2nxp11qV?u zAq%3pYgU;Bx2S?2;VS3u-c=_H4x!+xbisotc(qw@O&)^hPzS0s<*4#0S<)>_4x{Ah z>5_*~(jiO!a1KhA)?ZteD*EFrY3!}Cq)V1OfxJIQCYbgp1-q|5;@(|-p!9&O?j==d z9Tn<%K^EE-McGu*lO*eTflNphJtP6OBvo{e1jI+G=qnOXnc=sI)^6E1-R=UjkGk3m zWRErdoWe>3T0I0_OuscC>5uQp3$Yd z+KWYU1%^_-G`!FpGo+`zVEES%+a`O zFgfo7@d51J(Vr?RHw~&B7#pc{BA6;3rwV&22QZx0n(fZN&@>c@)g$h!4kin6s+f}7 zU!fCo^UK2qicFthA%uCpc72F*Wd}~tB*p|zRcggjQrh)O;pUf{;g(OIe+OAUael|7 z`JEa1soxXlrmNZyy(W0gRX}x^@$SkYjO=RjY^fYZ=PaP;?j1wJ|3X>1UU6=IdHQ^5 zPloL-GC9-UOnLD*dDn*)`-q1f!|txvicNB>O@qvYLNkH>B9+yr7N3UhPc1Gmk7t_4 zY3A`9^H^veXPQSEpQ*)j%wsW*Rl6@ZilEe!THH}6L@!~V@cO|d?T9j4fQ6#Il?cOm8t7+ZsWLo~P+e6G5o zs=`E{U$CrxSPQ&zIwMve*#>>4U4fvV?kaG*aYHcC zTHAE9;ZMX%nj^6<8XfTxKW;k`B_bFOCd>onxgi)xw3f6Q!IoBt!p1gb3dt&rxIY$* zqAexC2sHvXb0yJ`FN{WolWk2#jB>3fon!F&a6H%&HUdaCT8vnUZo}$|F?Cd|F4%5_ z)#8dV3ScwT*ro7Iw|c$l`>ktsh|B@0V0u`A3iBR)9!21+Auhav zo;A4;lGo3PC~kF_#BeX_z&(#cC#NGifkS418DIvO0cL<1U2axaCv2p@MfLA;K9%ojrI%#%5X`j@2ROa1;8mO4t>oWQ<=?i+DVhE@)^=a6 z@@ac_D0~O{f$DR@liQ{Ar2F9_nXZomHe9?wN)D>_y{W>VsqlRjo>1Y(D$G;$pQFNJ z6)sTWZ&g^S#@Q7rd_$!-D!f7AX$tH9)Arg!Jr1eON5Jf+cs z##!a2%(+2`87^;a*>&aK@~Tpg$GgZ|Z}IwDw|HBUzE}X6usSqgzR=)p!kTxyfY3W< zTmvf7s~ccUJ{jpPUsq1+sC{89xuACg%4s!!z0qvMj4;+|boPC$jCKs>6!)6L9<`G0p#{*ZNpvtRiecexFkG6% zk`39qK)r#5B;k#|P||GCp>)&k_5 ztb-_P4lL3k*IP7j`pT7!PN#{4=)l93a~yK*0VO@AuztSuy2ARo(nkvG=SkD?pohxq z=Sa&H*3XY>6xPp;5`?ibS3g(yM}>uY9`kPs>*q5mh4u59i?OJW%IoJBHNdn=Oh1R( zs<3|U)Tgk1&h$E!#|kXHUK`C3)4`2?p0ZeB{rsc~7|o;CB}{h%j=-==x>g&$-G=X% zn5r?|0US{t|D}6T;w;KbP=KHD^znWe^Kx$fIVY6`X|*P1Ke6j9a-RC*SidhW=W zrp&8wMv9!cvdoi{%g*=1~hMCy~>F$O*-2VdaGKjMbW(@ve;R zeyP3|cYxXG&{-FG*VlL&N^8n1y%>M)+6vF9X5Uy^zrM7rs&c9Vb@jD%c!kH)FsWiW zcTZxj*|7DfbbB=<6_4QFayj0Gr$Zm1Iz$3 zzzi@0%m6dM3@`)C05iZ0Fayj0Gr$Zm1Iz$3zzi@0%m6dM3@`)C05iZ0Fayj0Gr$Zm z1Iz$3zzi@0%m6dM3@`)C05iZ0Fayj$CIhtpANG#HtD^E{TY8^!EjAaj?*G?{w^Hr< zucfuP(lHrPm|hemk58+cZmRwN_NslBey848C5%^2$K1I6^(No{?=JOWwsxg~uvMSl z|Bv>~Ave|je|hTraPnnwyjTlt)rU82J55FEp!X5en-tpl>r?s$Rer+6SK4LQ*M|KB zXuwCh+ z_teP|w(F1*d8B>G?gSxn|21!UE#99rB`zr!6NJ>q^VsZWB=aqUzU$zZB| z4xP8le08c6VJmNc>OO~2r7x=V5w_~XwJKXbOt{bCkkUurTO~)>st-xo^i8!z6tj^98?v$RH(52ZioZPmKR(?)hlA=ny$A;^ijRCBd*cD0cu9yx)R6;E zW25n9Ayy+Cc@-XmjBX1whxCmJ@;dKi`y6(n{ABwaKK=a{u6^CPy10AQz^u7%+;oEK zbizv$Q7iU3q`1j({@r<(B5oa^bl=bjPd1Z1yzij_7UaH)YY}Q*#9!FHhjkdAG!FE> zhcsTv(O9GO6K`NEQ5q1R(xtlc4u29U+;{TU8T(F_=j~a7PX!fR;2JG(xJLWCE*$B9 z#Bq3dg&6JYI(Ouu+;c}B?kXO6B)52^zw2*CUPRhZZqA{uY(uOW`jdsov`!VikUjjUgJo})}d4_9r$r)nQw4ul~ zDjZ*Uhw3Sybcc9~@;gzFa60x}h;lFHo;R{Ybk+~wCZ6qw?C31yBRkHP_V4pM4j(&T zpdIIpkbT3`#i;nLYxLo+O(Qp={K?!49xtN$r^7y!9a<_z_x(y7J~o%yJ8uNs_g<3k z8odNu?8`lW#4MY;X{1_}JCK)j_<^M}J(M z$>-2Q#gXE7=wcgQYQtB%MxTK`>IeMc04~uV;3T6z`W$AA%sflkyI-|!=t?mi}${zIPmVjb3HeRkknnva+jO9L}&E{hBJDK!*NH%Ocy6FW?vmey$ey_ za4ybi;-ysV!S|og*0K6w|B1rChb(9QJ7)YoOZUT~6VAOM6rP z-6`aQQ^;SLLVj=x`5P06n3+T;aF ze)%k!&#=j{51zTH)%8mLimGRs!e^`S4z!Evb4oR$NVi9YjjEgy3wNiE$#6j7c{3zl zqQc8n=u%;o3OA`Rpu(sMx2y0@72c@q`=$zCRpHwzJgV$Eq43|S@ER4aSD|jdZucKm zx!aXIUxk^i8bkVf3d9j{O8fPaTdVk&X#DX==lHa&DW{q*Yhg}g2ABb6fEi#0m;q*h z8DIvO0cL<1UGGw$F;e@zF z^jgJkBiPcK$V{sXwi}^!kyxA0Cb%IONVJOH*)}_BJhe?X8~%id?ahO7vjx?a4W(nP zQ1Z^=UdX+XrknjVm?kl~9PWUHAY zW+Z&Uu;?vbUyIHj)qKp!?C|QlqCMo-o=f zjAmall&CPa24Na99n8T9s_|4sS_mD=u~F0P3x-H=BnMq+W!aOA8nM+R8cK^Ky`@PE zD%rX~y@9b7-slS@&B}(;S>u!kbVb5cFFA)C1>!scd7}4@92^heD01|us7CjN(nPt9 z5KIu~SjSD)9J5K|-nj#(FIyIxH;({Z2Na)!Z&B<$OX9~Ac2Af1b%hfOf244q!qf5L z3@Sg3KGFcUT;U}O*CA8f3_?PmZV#1!|NnYmv5CAt(NY~HoV(bzE9$G`<}PqAKCDG zHvD^u)Ar?}Wk^cnxi-AmhF9D0MjLLGINcul?#Q|DbUL07Pv^}A@QdJ=!LNW{34byC z68KBtFNI$U|5^Cu@YLt4;7?T-vI?ijoX@hJ(c>qK?AgXHv*WI>V+pnkYo@?*MV(Q?$!0M@?xwLBdg44|V`{=Y0mByxMf{VQZV=4&ykBY|;sC@xl4jG09hTE=6^ zHo8gGecKj{w)vt_BY#mVNbt?+oW4Hcbz?1ZeZ#SQsnSuL73 zme#K?Evu@WT=ybxU43nxw`#qoVN%8Nj%kwm%{%V(sB{7gmwTz>)U|lhd;xhYDXE$? zr|JptWS)G{Sa2)d=}PzUFQyIRR*$9-Olzi_jWo}X#KfUtf`0Sl)w{QFE%zuAi-tk$f zk)CDu|L_~nuD Date: Fri, 14 Nov 2025 11:22:07 +0100 Subject: [PATCH 04/50] 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 7a9a798f..6d3876fd 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 05/50] 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 6d3876fd..89681257 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 06/50] 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 89681257..745172f3 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 From 6dbb1e1a9146a0853f003b4cd5d1f9dde59e99a2 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Mon, 17 Nov 2025 15:36:53 +0100 Subject: [PATCH 07/50] Re-implement precise sleep --- rendercanvas/_scheduler.py | 20 +++----------------- rendercanvas/qt.py | 20 +++++++++++++++++++- rendercanvas/utils/asyncs.py | 23 +++++++++++++++++++++-- 3 files changed, 43 insertions(+), 20 deletions(-) diff --git a/rendercanvas/_scheduler.py b/rendercanvas/_scheduler.py index 5185318e..26d52af0 100644 --- a/rendercanvas/_scheduler.py +++ b/rendercanvas/_scheduler.py @@ -10,9 +10,6 @@ from .utils.asyncs import sleep, Event -IS_WIN = sys.platform.startswith("win") - - class Scheduler: """Helper class to schedule event processing and drawing.""" @@ -121,20 +118,9 @@ async def __scheduler_task(self): # Determine amount of sleep sleep_time = delay - (time.perf_counter() - last_tick_time) - if IS_WIN: - # On Windows OS-level timers have an in accuracy of 15.6 ms. - # This can cause sleep to take longer than intended. So we sleep - # less, and then do a few small sync-sleeps that have high accuracy. - await sleep(max(0, sleep_time - 0.0156)) - sleep_time = delay - (time.perf_counter() - last_tick_time) - while sleep_time > 0: - time.sleep(min(sleep_time, 0.001)) # sleep hard for at most 1ms - await sleep(0) # Allow other tasks to run but don't wait - sleep_time = delay - (time.perf_counter() - last_tick_time) - else: - # Wait. Even if delay is zero, it gives control back to the loop, - # allowing other tasks to do work. - await sleep(max(0, sleep_time)) + # Wait. Even if delay is zero, it gives control back to the loop, + # allowing other tasks to do work. + await sleep(max(0, sleep_time)) # Below is the "tick" diff --git a/rendercanvas/qt.py b/rendercanvas/qt.py index bafbcd81..4176badb 100644 --- a/rendercanvas/qt.py +++ b/rendercanvas/qt.py @@ -34,6 +34,7 @@ WA_DeleteOnClose = QtCore.Qt.WidgetAttribute.WA_DeleteOnClose WA_InputMethodEnabled = QtCore.Qt.WidgetAttribute.WA_InputMethodEnabled KeyboardModifiers = QtCore.Qt.KeyboardModifier + PreciseTimer = QtCore.Qt.TimerType.PreciseTimer FocusPolicy = QtCore.Qt.FocusPolicy CursorShape = QtCore.Qt.CursorShape Keys = QtCore.Qt.Key @@ -43,6 +44,7 @@ WA_PaintOnScreen = QtCore.Qt.WA_PaintOnScreen WA_DeleteOnClose = QtCore.Qt.WA_DeleteOnClose WA_InputMethodEnabled = QtCore.Qt.WA_InputMethodEnabled + PreciseTimer = QtCore.Qt.PreciseTimer KeyboardModifiers = QtCore.Qt FocusPolicy = QtCore.Qt CursorShape = QtCore.Qt @@ -180,6 +182,20 @@ def enable_hidpi(): "Qt falling back to offscreen rendering, which is less performant." ) +class CallbackWrapper(QtCore.QObject): + + def __init__(self, pool, cb): + super().__init__() + self.pool = pool + self.pool.add(self) + self.cb = cb + + @QtCore.Slot() + def callback(self): + self.pool.discard(self) + self.pool = None + self.cb() + class QtLoop(BaseLoop): _app = None @@ -192,6 +208,7 @@ def _rc_init(self): self._app = QtWidgets.QApplication([]) if already_had_app_on_import: self._mark_as_interactive() + self._callback_pool = set() def _rc_run(self): # Note: we could detect if asyncio is running (interactive session) and wheter @@ -228,7 +245,8 @@ def _rc_add_task(self, async_func, name): def _rc_call_later(self, delay, callback): delay_ms = int(max(0, delay * 1000)) - QtCore.QTimer.singleShot(delay_ms, callback) + callback_wrapper = CallbackWrapper(self._callback_pool, callback) + QtCore.QTimer.singleShot(delay_ms, PreciseTimer, callback_wrapper, QtCore.SLOT("callback()")) loop = QtLoop() diff --git a/rendercanvas/utils/asyncs.py b/rendercanvas/utils/asyncs.py index 33078853..193ada69 100644 --- a/rendercanvas/utils/asyncs.py +++ b/rendercanvas/utils/asyncs.py @@ -11,15 +11,34 @@ """ +from time import sleep as time_sleep import sys import sniffio +IS_WIN = sys.platform.startswith("win") + +thread_pool = None + + +def get_thread_pool_executor(): + global thread_pool + if thread_pool is not None: + + from concurrent.futures import ThreadPoolExecutor + thread_pool = ThreadPoolExecutor(16, "rendercanvas-threadpool") + return thread_pool + + async def sleep(delay): """Generic async sleep. Works with trio, asyncio and rendercanvas-native.""" libname = sniffio.current_async_library() - sleep = sys.modules[libname].sleep - await sleep(delay) + if IS_WIN and libname == 'asyncio': + executor = get_thread_pool_executor() + await sys.modules[libname].get_running_loop().run_in_executor(executor, time_sleep, delay) + else: + sleep = sys.modules[libname].sleep + await sleep(delay) class Event: From ecd3804745a4bb6ba21755bfa44d48e022689a8a Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Mon, 17 Nov 2025 16:16:21 +0100 Subject: [PATCH 08/50] improve accuracy of raw loop --- rendercanvas/raw.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/rendercanvas/raw.py b/rendercanvas/raw.py index 351144d4..48d52f25 100644 --- a/rendercanvas/raw.py +++ b/rendercanvas/raw.py @@ -46,8 +46,11 @@ def _rc_init(self): pass def _rc_run(self): + perf_counter = time.perf_counter + event = self._event + while not self._should_stop: - self._event.clear() + event.clear() # Get wrapper for callback that is first to be called try: @@ -61,11 +64,18 @@ def _rc_run(self): else: # Wait until its time for it to be called # Note that on Windows, the accuracy of the timeout is 15.6 ms - wait_time = wrapper.time - time.perf_counter() - self._event.wait(max(wait_time, 0)) + sleep_time = wrapper.time - perf_counter() + sleep_time = max(0, sleep_time - 0.0156) + event.wait(sleep_time) + + # Wait some more + sleep_time = wrapper.time - perf_counter() + while not event.is_set() and sleep_time > 0: + time.sleep(min(sleep_time, 0.001)) # sleep hard for at most 1ms + sleep_time = wrapper.time - perf_counter() # Put it back or call it? - if time.perf_counter() < wrapper.time: + if perf_counter() < wrapper.time: heapq.heappush(self._queue, wrapper) elif wrapper.callback is not None: try: From b91a4ace9fde032cf8046601279fc7b97ae70880 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Mon, 17 Nov 2025 16:17:42 +0100 Subject: [PATCH 09/50] ruff --- rendercanvas/_scheduler.py | 1 - rendercanvas/qt.py | 6 ++++-- rendercanvas/utils/asyncs.py | 10 +++++++--- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/rendercanvas/_scheduler.py b/rendercanvas/_scheduler.py index 26d52af0..1a196f83 100644 --- a/rendercanvas/_scheduler.py +++ b/rendercanvas/_scheduler.py @@ -2,7 +2,6 @@ The scheduler class/loop. """ -import sys import time import weakref diff --git a/rendercanvas/qt.py b/rendercanvas/qt.py index 4176badb..c05fc06a 100644 --- a/rendercanvas/qt.py +++ b/rendercanvas/qt.py @@ -182,8 +182,8 @@ def enable_hidpi(): "Qt falling back to offscreen rendering, which is less performant." ) -class CallbackWrapper(QtCore.QObject): +class CallbackWrapper(QtCore.QObject): def __init__(self, pool, cb): super().__init__() self.pool = pool @@ -246,7 +246,9 @@ def _rc_add_task(self, async_func, name): def _rc_call_later(self, delay, callback): delay_ms = int(max(0, delay * 1000)) callback_wrapper = CallbackWrapper(self._callback_pool, callback) - QtCore.QTimer.singleShot(delay_ms, PreciseTimer, callback_wrapper, QtCore.SLOT("callback()")) + QtCore.QTimer.singleShot( + delay_ms, PreciseTimer, callback_wrapper, QtCore.SLOT("callback()") + ) loop = QtLoop() diff --git a/rendercanvas/utils/asyncs.py b/rendercanvas/utils/asyncs.py index 193ada69..5de9db29 100644 --- a/rendercanvas/utils/asyncs.py +++ b/rendercanvas/utils/asyncs.py @@ -24,8 +24,8 @@ def get_thread_pool_executor(): global thread_pool if thread_pool is not None: - from concurrent.futures import ThreadPoolExecutor + thread_pool = ThreadPoolExecutor(16, "rendercanvas-threadpool") return thread_pool @@ -33,9 +33,13 @@ def get_thread_pool_executor(): async def sleep(delay): """Generic async sleep. Works with trio, asyncio and rendercanvas-native.""" libname = sniffio.current_async_library() - if IS_WIN and libname == 'asyncio': + if IS_WIN and libname == "asyncio": executor = get_thread_pool_executor() - await sys.modules[libname].get_running_loop().run_in_executor(executor, time_sleep, delay) + await ( + sys.modules[libname] + .get_running_loop() + .run_in_executor(executor, time_sleep, delay) + ) else: sleep = sys.modules[libname].sleep await sleep(delay) From 39903dcd0b6a386d0580dee7f4113bc7d0f4a257 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Mon, 17 Nov 2025 16:22:17 +0100 Subject: [PATCH 10/50] docs --- rendercanvas/utils/asyncs.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/rendercanvas/utils/asyncs.py b/rendercanvas/utils/asyncs.py index 5de9db29..94149dd5 100644 --- a/rendercanvas/utils/asyncs.py +++ b/rendercanvas/utils/asyncs.py @@ -16,7 +16,7 @@ import sniffio -IS_WIN = sys.platform.startswith("win") +IS_WIN = sys.platform.startswith("win") # Note that IS_WIN is false on Pyodide thread_pool = None @@ -31,7 +31,10 @@ def get_thread_pool_executor(): async def sleep(delay): - """Generic async sleep. Works with trio, asyncio and rendercanvas-native.""" + """Generic async sleep. Works with trio, asyncio and rendercanvas-native. + + For asyncio on Windows, this uses a special sleep routine that is more accurate than ``asyncio.sleep()``. + """ libname = sniffio.current_async_library() if IS_WIN and libname == "asyncio": executor = get_thread_pool_executor() From dd3b3302f7eb4df97c8ebeb15e56b9052e6ba2b9 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 18 Nov 2025 13:16:19 +0100 Subject: [PATCH 11/50] Try a scheduler thread util --- rendercanvas/_coreutils.py | 116 +++++++++++++++++++++++++++++++++++ rendercanvas/utils/asyncs.py | 27 +++++--- tests/test_utils.py | 57 +++++++++++++++++ 3 files changed, 192 insertions(+), 8 deletions(-) diff --git a/rendercanvas/_coreutils.py b/rendercanvas/_coreutils.py index 91144623..6c567b52 100644 --- a/rendercanvas/_coreutils.py +++ b/rendercanvas/_coreutils.py @@ -6,12 +6,21 @@ import re import sys import time +import heapq +import queue import weakref import logging +import threading import ctypes.util from contextlib import contextmanager +# %% Constants + + +IS_WIN = sys.platform.startswith("win") # Note that IS_WIN is false on Pyodide + + # %% Logging @@ -93,6 +102,113 @@ def proxy(*args, **kwargs): return proxy +# %% Helper for scheduling call-laters + + +class SchedulerTimeoutThread(threading.Thread): + class Item: + def __init__(self, index, time, callback, args): + self.index = index + self.time = time # measured in time.perf_counter + self.callback = callback + self.args = args + + def __lt__(self, other): + return (self.time, self.index) < (other.time, other.index) + + def cancel(self): + self.callback = None + self.args = None + + def __init__(self): + super().__init__() + self._queue = queue.SimpleQueue() + self._count = 0 + self._shutdown = False + self.daemon = True # don't let this thread prevent shutdown + self.start() + + def call_later_from_thread(self, delay, callback, *args): + """In delay seconds, call the callback from the scheduling thread.""" + self._count += 1 + item = SchedulerTimeoutThread.Item( + self._count, time.perf_counter() + float(delay), callback, args + ) + self._queue.put(item) + + def run(self): + perf_counter = time.perf_counter + Empty = queue.Empty # noqa: N806 + q = self._queue + priority = [] + is_win = IS_WIN + + wait_until = None + + while True: + # == Wait for input + + if wait_until is None: + # Nothing to do but wait + new_item = q.get(True, None) + elif not is_win: + # Wait for as long we can + try: + new_item = q.get(True, max(0, wait_until - perf_counter())) + except Empty: + new_item = None + else: + # Trickery to work around limited Windows timer precision + try: + new_item = q.get(True, max(0, wait_until - perf_counter() - 0.0156)) + except Empty: + new_item = None + while perf_counter() < wait_until: + time.sleep(0.001) # sleep hard for 1ms + try: + new_item = q.get_nowait() + break + except Empty: + pass + + # Put it in our priority queue + if new_item is not None: + heapq.heappush(priority, new_item) + + del new_item + + # == Process items until we have to wait + + item = None + while True: + # Get item that is up next + try: + item = heapq.heappop(priority) + except IndexError: + wait_until = None + break + + # If it's not yet time for the item, put it back, and go wait + if perf_counter() < item.time: + heapq.heappush(priority, item) + wait_until = item.time + break + + # Otherwise, handle the callback + try: + item.callback(*item.args) + except Exception as err: + logger.error(f"Error in callback: {err}") + + # Clear + item.cancel() + + del item + + +scheduler_timeout_thread = SchedulerTimeoutThread() + + # %% lib support diff --git a/rendercanvas/utils/asyncs.py b/rendercanvas/utils/asyncs.py index 94149dd5..3515a839 100644 --- a/rendercanvas/utils/asyncs.py +++ b/rendercanvas/utils/asyncs.py @@ -15,8 +15,8 @@ import sys import sniffio +from .._coreutils import IS_WIN, scheduler_timeout_thread -IS_WIN = sys.platform.startswith("win") # Note that IS_WIN is false on Pyodide thread_pool = None @@ -36,13 +36,24 @@ async def sleep(delay): For asyncio on Windows, this uses a special sleep routine that is more accurate than ``asyncio.sleep()``. """ libname = sniffio.current_async_library() - if IS_WIN and libname == "asyncio": - executor = get_thread_pool_executor() - await ( - sys.modules[libname] - .get_running_loop() - .run_in_executor(executor, time_sleep, delay) - ) + # if IS_WIN and libname == "asyncio" and delay > 0: + if True and libname == "asyncio" and delay > 0: + if True: + asyncio = sys.modules[libname] + loop = asyncio.get_running_loop() + event = asyncio.Event() + offset = 0.002 # there is some overhead for going to a thread and back + scheduler_timeout_thread.call_later_from_thread( + delay - offset, loop.call_soon_threadsafe, event.set + ) + await event.wait() + else: + executor = get_thread_pool_executor() + await ( + sys.modules[libname] + .get_running_loop() + .run_in_executor(executor, time_sleep, delay) + ) else: sleep = sys.modules[libname].sleep await sleep(delay) diff --git a/tests/test_utils.py b/tests/test_utils.py index ebe43b26..f32635f6 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,4 +1,5 @@ import gc +import time import rendercanvas from testutils import run_tests, is_pypy @@ -38,5 +39,61 @@ def bar(self): assert len(xx) == 3 # f2 is gone! +def test_call_later_thread(): + t = rendercanvas._coreutils.scheduler_timeout_thread + + results = [] + + # Call now + t.call_later_from_thread(0, results.append, 5) + + time.sleep(0.01) + assert results == [5] + + # Call later + t.call_later_from_thread(0.5, results.append, 5) + + time.sleep(0.1) + assert results == [5] + + time.sleep(0.5) + assert results == [5, 5] + + # Call multiple at same time + results.clear() + t.call_later_from_thread(0, results.append, 1) + t.call_later_from_thread(0, results.append, 2) + t.call_later_from_thread(0, results.append, 3) + t.call_later_from_thread(0.1, results.append, 4) + t.call_later_from_thread(0.1, results.append, 5) + t.call_later_from_thread(0.1, results.append, 6) + + time.sleep(0.11) + assert results == [1, 2, 3, 4, 5, 6] + + # Out of order + + def set(x): + results.append((x, time.perf_counter())) + + results.clear() + t.call_later_from_thread(0.9, set, 1) + t.call_later_from_thread(0.8, set, 2) + t.call_later_from_thread(0.41, set, 3) + t.call_later_from_thread(0.40, set, 4) + t.call_later_from_thread(0.11, set, 5) + t.call_later_from_thread(0.10, set, 6) + + now = time.perf_counter() + time.sleep(1.1) + + indices = [r[0] for r in results] + times = [r[1] - now for r in results] + + assert indices == [6, 5, 4, 3, 2, 1] + assert times[1] - times[0] < 0.015 + assert times[2] - times[3] < 0.015 + + if __name__ == "__main__": run_tests(globals()) From 2b74282de793bfc6dbfbb013c9e362bca6745887 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 18 Nov 2025 23:11:10 +0100 Subject: [PATCH 12/50] improvinh --- rendercanvas/_coreutils.py | 41 ++++++++++++++++++++++++------------ rendercanvas/utils/asyncs.py | 14 ++++++------ tests/test_utils.py | 2 +- 3 files changed, 35 insertions(+), 22 deletions(-) diff --git a/rendercanvas/_coreutils.py b/rendercanvas/_coreutils.py index 6c567b52..8411f21b 100644 --- a/rendercanvas/_coreutils.py +++ b/rendercanvas/_coreutils.py @@ -105,7 +105,14 @@ def proxy(*args, **kwargs): # %% Helper for scheduling call-laters -class SchedulerTimeoutThread(threading.Thread): +class CallLaterThread(threading.Thread): + """A thread object that can be used to do "call later" from a dedicated thread. + + Care is taken to realize precise timing, so it can be used to implement + precise sleeping and call_later on Windows (to overcome Windows notorious + 15.6ms ticks). + """ + class Item: def __init__(self, index, time, callback, args): self.index = index @@ -131,10 +138,11 @@ def __init__(self): def call_later_from_thread(self, delay, callback, *args): """In delay seconds, call the callback from the scheduling thread.""" self._count += 1 - item = SchedulerTimeoutThread.Item( + item = CallLaterThread.Item( self._count, time.perf_counter() + float(delay), callback, args ) self._queue.put(item) + # TODO: could return a futures.Promise? def run(self): perf_counter = time.perf_counter @@ -144,6 +152,7 @@ def run(self): is_win = IS_WIN wait_until = None + leeway = 0.0005 # a little 0.5ms offset, because we take 1 ms steps while True: # == Wait for input @@ -151,16 +160,14 @@ def run(self): if wait_until is None: # Nothing to do but wait new_item = q.get(True, None) - elif not is_win: - # Wait for as long we can - try: - new_item = q.get(True, max(0, wait_until - perf_counter())) - except Empty: - new_item = None else: - # Trickery to work around limited Windows timer precision + # We wait for the queue with a timeout. But because the timeout is not very precise, + # we wait shorter, and then go in a loop with some hard sleeps. + # Windows has 15.6 ms resolution ticks. But also on other OSes, + # it benefits precision to do the last bit with hard sleeps. + offset = 0.016 if is_win else 0.004 try: - new_item = q.get(True, max(0, wait_until - perf_counter() - 0.0156)) + new_item = q.get(True, max(0, wait_until - perf_counter() - offset)) except Empty: new_item = None while perf_counter() < wait_until: @@ -189,9 +196,10 @@ def run(self): break # If it's not yet time for the item, put it back, and go wait - if perf_counter() < item.time: + item_time_threshold = item.time - leeway + if perf_counter() < item_time_threshold: heapq.heappush(priority, item) - wait_until = item.time + wait_until = item_time_threshold break # Otherwise, handle the callback @@ -206,7 +214,14 @@ def run(self): del item -scheduler_timeout_thread = SchedulerTimeoutThread() +call_later_thread = None + + +def get_call_later_thread(): + global call_later_thread + if call_later_thread is None: + call_later_thread = CallLaterThread() + return call_later_thread # %% lib support diff --git a/rendercanvas/utils/asyncs.py b/rendercanvas/utils/asyncs.py index 3515a839..cb1212df 100644 --- a/rendercanvas/utils/asyncs.py +++ b/rendercanvas/utils/asyncs.py @@ -13,9 +13,11 @@ from time import sleep as time_sleep import sys +from concurrent.futures import Future as _Future + import sniffio -from .._coreutils import IS_WIN, scheduler_timeout_thread +from .._coreutils import IS_WIN, get_call_later_thread thread_pool = None @@ -40,13 +42,9 @@ async def sleep(delay): if True and libname == "asyncio" and delay > 0: if True: asyncio = sys.modules[libname] - loop = asyncio.get_running_loop() - event = asyncio.Event() - offset = 0.002 # there is some overhead for going to a thread and back - scheduler_timeout_thread.call_later_from_thread( - delay - offset, loop.call_soon_threadsafe, event.set - ) - await event.wait() + f = _Future() + get_call_later_thread().call_later_from_thread(delay, f.set_result, None) + await asyncio.wrap_future(f) else: executor = get_thread_pool_executor() await ( diff --git a/tests/test_utils.py b/tests/test_utils.py index f32635f6..382d8aa7 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -40,7 +40,7 @@ def bar(self): def test_call_later_thread(): - t = rendercanvas._coreutils.scheduler_timeout_thread + t = rendercanvas._coreutils.CallLaterThread() results = [] From 8554cdac423153ca0e83762323bc619131d09753 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 18 Nov 2025 23:29:32 +0100 Subject: [PATCH 13/50] Also apply for trio --- rendercanvas/utils/asyncs.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/rendercanvas/utils/asyncs.py b/rendercanvas/utils/asyncs.py index cb1212df..1701118c 100644 --- a/rendercanvas/utils/asyncs.py +++ b/rendercanvas/utils/asyncs.py @@ -52,6 +52,14 @@ async def sleep(delay): .get_running_loop() .run_in_executor(executor, time_sleep, delay) ) + elif True and libname == "trio" and delay > 0: + trio = sys.modules[libname] + f = _Future() + event = trio.Event() + token = trio.lowlevel.current_trio_token() + get_call_later_thread().call_later_from_thread(delay, token.run_sync_soon, event.set) + await event.wait() + else: sleep = sys.modules[libname].sleep await sleep(delay) From 493924ed355b6aac86eaa446726ca1ce9c4776bb Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 18 Nov 2025 23:39:32 +0100 Subject: [PATCH 14/50] tiny tweak --- rendercanvas/_coreutils.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/rendercanvas/_coreutils.py b/rendercanvas/_coreutils.py index 8411f21b..77332dd3 100644 --- a/rendercanvas/_coreutils.py +++ b/rendercanvas/_coreutils.py @@ -152,7 +152,8 @@ def run(self): is_win = IS_WIN wait_until = None - leeway = 0.0005 # a little 0.5ms offset, because we take 1 ms steps + timestep = 0.001 # for doing small sleeps + leeway = timestep / 2 # a little offset so waiting exactly right on average while True: # == Wait for input @@ -171,7 +172,7 @@ def run(self): except Empty: new_item = None while perf_counter() < wait_until: - time.sleep(0.001) # sleep hard for 1ms + time.sleep(timestep) try: new_item = q.get_nowait() break From aba63ccfd70ecd788723777a884046d804d44c8f Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 19 Nov 2025 08:56:52 +0100 Subject: [PATCH 15/50] Implement for wx --- rendercanvas/wx.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/rendercanvas/wx.py b/rendercanvas/wx.py index 09b4cd0b..e9401252 100644 --- a/rendercanvas/wx.py +++ b/rendercanvas/wx.py @@ -17,6 +17,8 @@ SYSTEM_IS_WAYLAND, get_alt_x11_display, get_alt_wayland_display, + IS_WIN, + get_call_later_thread, ) from .base import ( WrapperRenderCanvas, @@ -179,7 +181,14 @@ def _rc_add_task(self, async_func, name): return super()._rc_add_task(async_func, name) def _rc_call_later(self, delay, callback): - wx.CallLater(max(int(delay * 1000), 1), callback) + if delay <= 0: + wx.CallAfter(callback) + elif IS_WIN: + get_call_later_thread().call_later_from_thread( + delay, wx.CallAfter, callback + ) + else: + wx.CallLater(max(int(delay * 1000), 1), callback) def process_wx_events(self): old_loop = wx.GUIEventLoop.GetActive() From 2591017ac34a7ea4555e0362ab33722d1904ca9a Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 19 Nov 2025 10:26:11 +0100 Subject: [PATCH 16/50] Make precise timers and threaded timers work for all qt backends --- rendercanvas/qt.py | 71 +++++++++++++++++++++++++++--------- rendercanvas/utils/asyncs.py | 4 +- rendercanvas/wx.py | 2 +- 3 files changed, 58 insertions(+), 19 deletions(-) diff --git a/rendercanvas/qt.py b/rendercanvas/qt.py index c05fc06a..ed1c942c 100644 --- a/rendercanvas/qt.py +++ b/rendercanvas/qt.py @@ -17,6 +17,8 @@ get_alt_x11_display, get_alt_wayland_display, select_qt_lib, + IS_WIN, + get_call_later_thread, ) @@ -28,28 +30,35 @@ QtWidgets = importlib.import_module(".QtWidgets", libname) # Uncomment the line below to try QtOpenGLWidgets.QOpenGLWidget instead of QWidget # QtOpenGLWidgets = importlib.import_module(".QtOpenGLWidgets", libname) - try: - # pyqt6 - WA_PaintOnScreen = QtCore.Qt.WidgetAttribute.WA_PaintOnScreen - WA_DeleteOnClose = QtCore.Qt.WidgetAttribute.WA_DeleteOnClose + if libname.startswith("PyQt"): + # PyQt5 or PyQt6 WA_InputMethodEnabled = QtCore.Qt.WidgetAttribute.WA_InputMethodEnabled KeyboardModifiers = QtCore.Qt.KeyboardModifier + WA_PaintOnScreen = QtCore.Qt.WidgetAttribute.WA_PaintOnScreen + WA_DeleteOnClose = QtCore.Qt.WidgetAttribute.WA_DeleteOnClose PreciseTimer = QtCore.Qt.TimerType.PreciseTimer FocusPolicy = QtCore.Qt.FocusPolicy CursorShape = QtCore.Qt.CursorShape - Keys = QtCore.Qt.Key WinIdChange = QtCore.QEvent.Type.WinIdChange - except AttributeError: - # pyside6 + Signal = QtCore.pyqtSignal + Slot = QtCore.pyqtSlot + Keys = QtCore.Qt.Key + is_pyside = False + else: + # Pyside2 or PySide6 + WA_InputMethodEnabled = QtCore.Qt.WA_InputMethodEnabled + KeyboardModifiers = QtCore.Qt WA_PaintOnScreen = QtCore.Qt.WA_PaintOnScreen WA_DeleteOnClose = QtCore.Qt.WA_DeleteOnClose - WA_InputMethodEnabled = QtCore.Qt.WA_InputMethodEnabled PreciseTimer = QtCore.Qt.PreciseTimer - KeyboardModifiers = QtCore.Qt FocusPolicy = QtCore.Qt CursorShape = QtCore.Qt - Keys = QtCore.Qt WinIdChange = QtCore.QEvent.WinIdChange + Signal = QtCore.Signal + Slot = QtCore.Slot + Keys = QtCore.Qt + is_pyside = True + else: raise ImportError( "Before importing rendercanvas.qt, import one of PySide6/PySide2/PyQt6/PyQt5 to select a Qt toolkit." @@ -183,20 +192,32 @@ def enable_hidpi(): ) -class CallbackWrapper(QtCore.QObject): +class CallbackWrapperHelper(QtCore.QObject): + """Little helper for the high-precision-timer call-laters.""" + def __init__(self, pool, cb): super().__init__() self.pool = pool self.pool.add(self) self.cb = cb - @QtCore.Slot() + @Slot() def callback(self): self.pool.discard(self) self.pool = None self.cb() +class CallerHelper(QtCore.QObject): + """Little helper class for the threaded call-laters.""" + + call = Signal(object) + + def __init__(self): + super().__init__() + self.call.connect(lambda f: f()) + + class QtLoop(BaseLoop): _app = None _we_run_the_loop = False @@ -209,6 +230,7 @@ def _rc_init(self): if already_had_app_on_import: self._mark_as_interactive() self._callback_pool = set() + self._caller = CallerHelper() def _rc_run(self): # Note: we could detect if asyncio is running (interactive session) and wheter @@ -244,11 +266,26 @@ def _rc_add_task(self, async_func, name): return super()._rc_add_task(async_func, name) def _rc_call_later(self, delay, callback): - delay_ms = int(max(0, delay * 1000)) - callback_wrapper = CallbackWrapper(self._callback_pool, callback) - QtCore.QTimer.singleShot( - delay_ms, PreciseTimer, callback_wrapper, QtCore.SLOT("callback()") - ) + if delay <= 0: + QtCore.QTimer.singleShot(0, callback) + elif IS_WIN: + # Use high precision timer. We can use the threaded approach, + # or use Qt's own high precision timer. + # We chose the latter, which seems slightly more accurate. + if False: + get_call_later_thread().call_later_from_thread( + delay, self._caller.call.emit, callback + ) + else: + callback_wrapper = CallbackWrapperHelper(self._callback_pool, callback) + wrapper_args = (callback_wrapper.callback,) + if is_pyside: + wrapper_args = (callback_wrapper, QtCore.SLOT("callback()")) + QtCore.QTimer.singleShot( + int(max(delay * 1000, 1)), PreciseTimer, *wrapper_args + ) + else: + QtCore.QTimer.singleShot(int(max(delay * 1000, 1)), callback) loop = QtLoop() diff --git a/rendercanvas/utils/asyncs.py b/rendercanvas/utils/asyncs.py index 1701118c..f3b390cb 100644 --- a/rendercanvas/utils/asyncs.py +++ b/rendercanvas/utils/asyncs.py @@ -57,7 +57,9 @@ async def sleep(delay): f = _Future() event = trio.Event() token = trio.lowlevel.current_trio_token() - get_call_later_thread().call_later_from_thread(delay, token.run_sync_soon, event.set) + get_call_later_thread().call_later_from_thread( + delay, token.run_sync_soon, event.set + ) await event.wait() else: diff --git a/rendercanvas/wx.py b/rendercanvas/wx.py index e9401252..ca012195 100644 --- a/rendercanvas/wx.py +++ b/rendercanvas/wx.py @@ -188,7 +188,7 @@ def _rc_call_later(self, delay, callback): delay, wx.CallAfter, callback ) else: - wx.CallLater(max(int(delay * 1000), 1), callback) + wx.CallLater(int(max(delay * 1000, 1)), callback) def process_wx_events(self): old_loop = wx.GUIEventLoop.GetActive() From 19aa7a4c65ec01179bbca2cf207046048cfe5aed Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 19 Nov 2025 11:06:12 +0100 Subject: [PATCH 17/50] Clean up --- rendercanvas/_coreutils.py | 18 ++++++++++------ rendercanvas/qt.py | 34 +++++++++++++++-------------- rendercanvas/utils/asyncs.py | 42 ++++++++++-------------------------- rendercanvas/wx.py | 11 +++++----- 4 files changed, 47 insertions(+), 58 deletions(-) diff --git a/rendercanvas/_coreutils.py b/rendercanvas/_coreutils.py index 77332dd3..3871656a 100644 --- a/rendercanvas/_coreutils.py +++ b/rendercanvas/_coreutils.py @@ -215,14 +215,20 @@ def run(self): del item -call_later_thread = None +_call_later_thread = None -def get_call_later_thread(): - global call_later_thread - if call_later_thread is None: - call_later_thread = CallLaterThread() - return call_later_thread +def call_later_from_thread(delay: float, callback: object, *args: object): + """Utility that calls a callback after a specified delay, from a separate thread. + + The caller is responsible for the given callback to be thread-safe. + There is one global thread that handles all callbacks. This thread is spawned the first time + that this function is called. + """ + global _call_later_thread + if _call_later_thread is None: + _call_later_thread = CallLaterThread() + return _call_later_thread.call_later_from_thread(delay, callback, *args) # %% lib support diff --git a/rendercanvas/qt.py b/rendercanvas/qt.py index ed1c942c..50ec1513 100644 --- a/rendercanvas/qt.py +++ b/rendercanvas/qt.py @@ -18,9 +18,11 @@ get_alt_wayland_display, select_qt_lib, IS_WIN, - get_call_later_thread, + call_later_from_thread, ) +USE_THREADED_TIMER = False # Default False, because we use Qt PreciseTimer instead + # Select GUI toolkit libname, already_had_app_on_import = select_qt_lib() @@ -268,23 +270,23 @@ def _rc_add_task(self, async_func, name): def _rc_call_later(self, delay, callback): if delay <= 0: QtCore.QTimer.singleShot(0, callback) + elif USE_THREADED_TIMER: + call_later_from_thread(delay, self._caller.call.emit, callback) elif IS_WIN: - # Use high precision timer. We can use the threaded approach, - # or use Qt's own high precision timer. - # We chose the latter, which seems slightly more accurate. - if False: - get_call_later_thread().call_later_from_thread( - delay, self._caller.call.emit, callback - ) - else: - callback_wrapper = CallbackWrapperHelper(self._callback_pool, callback) - wrapper_args = (callback_wrapper.callback,) - if is_pyside: - wrapper_args = (callback_wrapper, QtCore.SLOT("callback()")) - QtCore.QTimer.singleShot( - int(max(delay * 1000, 1)), PreciseTimer, *wrapper_args - ) + # To get high-precision call_later in Windows, we can either use the threaded + # approach, or use Qt's own high-precision timer. We default to the latter, + # which seems slightly more accurate. It's a bit involved, because we need to + # make use of slots, and the signature for singleShot is not well-documented and + # differs between PyQt/PySide. + callback_wrapper = CallbackWrapperHelper(self._callback_pool, callback) + wrapper_args = (callback_wrapper.callback,) + if is_pyside: + wrapper_args = (callback_wrapper, QtCore.SLOT("callback()")) + QtCore.QTimer.singleShot( + int(max(delay * 1000, 1)), PreciseTimer, *wrapper_args + ) else: + # Normal timer. Already precise for MacOS/Linux. QtCore.QTimer.singleShot(int(max(delay * 1000, 1)), callback) diff --git a/rendercanvas/utils/asyncs.py b/rendercanvas/utils/asyncs.py index f3b390cb..42ce8a3d 100644 --- a/rendercanvas/utils/asyncs.py +++ b/rendercanvas/utils/asyncs.py @@ -11,25 +11,15 @@ """ -from time import sleep as time_sleep import sys from concurrent.futures import Future as _Future import sniffio -from .._coreutils import IS_WIN, get_call_later_thread +from .._coreutils import IS_WIN, call_later_from_thread -thread_pool = None - - -def get_thread_pool_executor(): - global thread_pool - if thread_pool is not None: - from concurrent.futures import ThreadPoolExecutor - - thread_pool = ThreadPoolExecutor(16, "rendercanvas-threadpool") - return thread_pool +USE_THREADED_TIMER = IS_WIN async def sleep(delay): @@ -38,30 +28,20 @@ async def sleep(delay): For asyncio on Windows, this uses a special sleep routine that is more accurate than ``asyncio.sleep()``. """ libname = sniffio.current_async_library() - # if IS_WIN and libname == "asyncio" and delay > 0: - if True and libname == "asyncio" and delay > 0: - if True: - asyncio = sys.modules[libname] - f = _Future() - get_call_later_thread().call_later_from_thread(delay, f.set_result, None) - await asyncio.wrap_future(f) - else: - executor = get_thread_pool_executor() - await ( - sys.modules[libname] - .get_running_loop() - .run_in_executor(executor, time_sleep, delay) - ) - elif True and libname == "trio" and delay > 0: + if libname == "asyncio" and delay > 0 and USE_THREADED_TIMER: + asyncio = sys.modules[libname] + f = _Future() + call_later_from_thread(delay, f.set_result, None) + await asyncio.wrap_future(f) + return + elif libname == "trio" and delay > 0 and USE_THREADED_TIMER: trio = sys.modules[libname] f = _Future() event = trio.Event() token = trio.lowlevel.current_trio_token() - get_call_later_thread().call_later_from_thread( - delay, token.run_sync_soon, event.set - ) + call_later_from_thread(delay, token.run_sync_soon, event.set) await event.wait() - + return else: sleep = sys.modules[libname].sleep await sleep(delay) diff --git a/rendercanvas/wx.py b/rendercanvas/wx.py index ca012195..7cf75162 100644 --- a/rendercanvas/wx.py +++ b/rendercanvas/wx.py @@ -18,7 +18,7 @@ get_alt_x11_display, get_alt_wayland_display, IS_WIN, - get_call_later_thread, + call_later_from_thread, ) from .base import ( WrapperRenderCanvas, @@ -28,6 +28,9 @@ ) +USE_THREADED_TIMER = IS_WIN + + BUTTON_MAP = { wx.MOUSE_BTN_LEFT: 1, wx.MOUSE_BTN_RIGHT: 2, @@ -183,10 +186,8 @@ def _rc_add_task(self, async_func, name): def _rc_call_later(self, delay, callback): if delay <= 0: wx.CallAfter(callback) - elif IS_WIN: - get_call_later_thread().call_later_from_thread( - delay, wx.CallAfter, callback - ) + elif USE_THREADED_TIMER: + call_later_from_thread(delay, wx.CallAfter, callback) else: wx.CallLater(int(max(delay * 1000, 1)), callback) From 9a019534721a97be652239c0dc44e61b3b67306f Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 19 Nov 2025 11:10:13 +0100 Subject: [PATCH 18/50] add comment --- rendercanvas/_coreutils.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/rendercanvas/_coreutils.py b/rendercanvas/_coreutils.py index 3871656a..654f4bde 100644 --- a/rendercanvas/_coreutils.py +++ b/rendercanvas/_coreutils.py @@ -224,6 +224,9 @@ def call_later_from_thread(delay: float, callback: object, *args: object): The caller is responsible for the given callback to be thread-safe. There is one global thread that handles all callbacks. This thread is spawned the first time that this function is called. + + Note that this function should only be used in environments where threading is available. + E.g. on Pyodide this will raise ``RuntimeError: can't start new thread``. """ global _call_later_thread if _call_later_thread is None: From 6270abb12010ffc0eb78d47e3b648bfa3dcb6d91 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 19 Nov 2025 11:21:33 +0100 Subject: [PATCH 19/50] simplify thread code a bit --- rendercanvas/_coreutils.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/rendercanvas/_coreutils.py b/rendercanvas/_coreutils.py index 654f4bde..b7820be5 100644 --- a/rendercanvas/_coreutils.py +++ b/rendercanvas/_coreutils.py @@ -6,13 +6,13 @@ import re import sys import time -import heapq import queue import weakref import logging import threading import ctypes.util from contextlib import contextmanager +from collections import namedtuple # %% Constants @@ -113,6 +113,8 @@ class CallLaterThread(threading.Thread): 15.6ms ticks). """ + Item = namedtuple("Item", ["time", "index", "callback", "args"]) + class Item: def __init__(self, index, time, callback, args): self.index = index @@ -181,7 +183,8 @@ def run(self): # Put it in our priority queue if new_item is not None: - heapq.heappush(priority, new_item) + priority.append(new_item) + priority.sort(reverse=True) del new_item @@ -191,7 +194,7 @@ def run(self): while True: # Get item that is up next try: - item = heapq.heappop(priority) + item = priority.pop(-1) except IndexError: wait_until = None break @@ -199,7 +202,7 @@ def run(self): # If it's not yet time for the item, put it back, and go wait item_time_threshold = item.time - leeway if perf_counter() < item_time_threshold: - heapq.heappush(priority, item) + priority.append(item) wait_until = item_time_threshold break From c06c5c5f1dd21379e11ae3913eff02037c35783f Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 19 Nov 2025 11:31:56 +0100 Subject: [PATCH 20/50] Avoid using Future.set_result, which we are not supposed to be calling --- rendercanvas/_coreutils.py | 1 - rendercanvas/utils/asyncs.py | 11 ++++------- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/rendercanvas/_coreutils.py b/rendercanvas/_coreutils.py index b7820be5..389f7851 100644 --- a/rendercanvas/_coreutils.py +++ b/rendercanvas/_coreutils.py @@ -144,7 +144,6 @@ def call_later_from_thread(self, delay, callback, *args): self._count, time.perf_counter() + float(delay), callback, args ) self._queue.put(item) - # TODO: could return a futures.Promise? def run(self): perf_counter = time.perf_counter diff --git a/rendercanvas/utils/asyncs.py b/rendercanvas/utils/asyncs.py index 42ce8a3d..6945dfa1 100644 --- a/rendercanvas/utils/asyncs.py +++ b/rendercanvas/utils/asyncs.py @@ -12,7 +12,6 @@ """ import sys -from concurrent.futures import Future as _Future import sniffio @@ -30,18 +29,16 @@ async def sleep(delay): libname = sniffio.current_async_library() if libname == "asyncio" and delay > 0 and USE_THREADED_TIMER: asyncio = sys.modules[libname] - f = _Future() - call_later_from_thread(delay, f.set_result, None) - await asyncio.wrap_future(f) - return + loop = asyncio.get_running_loop() + event = asyncio.Event() + call_later_from_thread(delay, loop.call_soon_threadsafe, event.set) + await event.wait() elif libname == "trio" and delay > 0 and USE_THREADED_TIMER: trio = sys.modules[libname] - f = _Future() event = trio.Event() token = trio.lowlevel.current_trio_token() call_later_from_thread(delay, token.run_sync_soon, event.set) await event.wait() - return else: sleep = sys.modules[libname].sleep await sleep(delay) From 5326bab5857b97254d5a375ec8a511f92025405d Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 19 Nov 2025 11:36:02 +0100 Subject: [PATCH 21/50] clean --- rendercanvas/_coreutils.py | 23 +++-------------------- 1 file changed, 3 insertions(+), 20 deletions(-) diff --git a/rendercanvas/_coreutils.py b/rendercanvas/_coreutils.py index 389f7851..ce4c653a 100644 --- a/rendercanvas/_coreutils.py +++ b/rendercanvas/_coreutils.py @@ -106,29 +106,15 @@ def proxy(*args, **kwargs): class CallLaterThread(threading.Thread): - """A thread object that can be used to do "call later" from a dedicated thread. + """An object that can be used to do "call later" from a dedicated thread. Care is taken to realize precise timing, so it can be used to implement - precise sleeping and call_later on Windows (to overcome Windows notorious + precise sleeping and call_later on Windows (to overcome Windows' notorious 15.6ms ticks). """ Item = namedtuple("Item", ["time", "index", "callback", "args"]) - class Item: - def __init__(self, index, time, callback, args): - self.index = index - self.time = time # measured in time.perf_counter - self.callback = callback - self.args = args - - def __lt__(self, other): - return (self.time, self.index) < (other.time, other.index) - - def cancel(self): - self.callback = None - self.args = None - def __init__(self): super().__init__() self._queue = queue.SimpleQueue() @@ -141,7 +127,7 @@ def call_later_from_thread(self, delay, callback, *args): """In delay seconds, call the callback from the scheduling thread.""" self._count += 1 item = CallLaterThread.Item( - self._count, time.perf_counter() + float(delay), callback, args + time.perf_counter() + float(delay), self._count, callback, args ) self._queue.put(item) @@ -211,9 +197,6 @@ def run(self): except Exception as err: logger.error(f"Error in callback: {err}") - # Clear - item.cancel() - del item From eeabd0174481d89413160a468e144543b87cc769 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 19 Nov 2025 11:40:18 +0100 Subject: [PATCH 22/50] comment --- rendercanvas/utils/asyncs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rendercanvas/utils/asyncs.py b/rendercanvas/utils/asyncs.py index 6945dfa1..8511e7c4 100644 --- a/rendercanvas/utils/asyncs.py +++ b/rendercanvas/utils/asyncs.py @@ -24,7 +24,7 @@ async def sleep(delay): """Generic async sleep. Works with trio, asyncio and rendercanvas-native. - For asyncio on Windows, this uses a special sleep routine that is more accurate than ``asyncio.sleep()``. + On Windows, with asyncio or trio, this uses a special sleep routine that is more accurate than the standard ``sleep()``. """ libname = sniffio.current_async_library() if libname == "asyncio" and delay > 0 and USE_THREADED_TIMER: From 8f6bbdafba9f55020a255d499151062b68aa261b Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 19 Nov 2025 12:08:41 +0100 Subject: [PATCH 23/50] Using the thread, the raw loop can become dead simple --- rendercanvas/_coreutils.py | 2 +- rendercanvas/raw.py | 73 +++++--------------------------------- 2 files changed, 10 insertions(+), 65 deletions(-) diff --git a/rendercanvas/_coreutils.py b/rendercanvas/_coreutils.py index ce4c653a..a6132e6a 100644 --- a/rendercanvas/_coreutils.py +++ b/rendercanvas/_coreutils.py @@ -195,7 +195,7 @@ def run(self): try: item.callback(*item.args) except Exception as err: - logger.error(f"Error in callback: {err}") + logger.error(f"Error in CallLaterThread callback: {err}") del item diff --git a/rendercanvas/raw.py b/rendercanvas/raw.py index 48d52f25..92c01842 100644 --- a/rendercanvas/raw.py +++ b/rendercanvas/raw.py @@ -8,80 +8,29 @@ __all__ = ["RawLoop", "loop"] -import time -import heapq -import logging -import threading -from itertools import count +import queue from .base import BaseLoop - - -logger = logging.getLogger("rendercanvas") -counter = count() - - -class CallAtWrapper: - def __init__(self, time, callback): - self.index = next(counter) - self.time = time - self.callback = callback - - def __lt__(self, other): - return (self.time, self.index) < (other.time, other.index) - - def cancel(self): - self.callback = None +from ._coreutils import logger, call_later_from_thread class RawLoop(BaseLoop): def __init__(self): super().__init__() - self._queue = [] # prioriry queue + self._queue = queue.SimpleQueue() self._should_stop = False - self._event = threading.Event() def _rc_init(self): # This gets called when the first canvas is created (possibly after having run and stopped before). pass def _rc_run(self): - perf_counter = time.perf_counter - event = self._event - while not self._should_stop: - event.clear() - - # Get wrapper for callback that is first to be called + callback = self._queue.get(True, None) try: - wrapper = heapq.heappop(self._queue) - except IndexError: - wrapper = None - - if wrapper is None: - # Empty queue, exit - break - else: - # Wait until its time for it to be called - # Note that on Windows, the accuracy of the timeout is 15.6 ms - sleep_time = wrapper.time - perf_counter() - sleep_time = max(0, sleep_time - 0.0156) - event.wait(sleep_time) - - # Wait some more - sleep_time = wrapper.time - perf_counter() - while not event.is_set() and sleep_time > 0: - time.sleep(min(sleep_time, 0.001)) # sleep hard for at most 1ms - sleep_time = wrapper.time - perf_counter() - - # Put it back or call it? - if perf_counter() < wrapper.time: - heapq.heappush(self._queue, wrapper) - elif wrapper.callback is not None: - try: - wrapper.callback() - except Exception as err: - logger.error(f"Error in callback: {err}") + callback() + except Exception as err: + logger.error(f"Error in RawLoop callback: {err}") async def _rc_run_async(self): raise NotImplementedError() @@ -89,18 +38,14 @@ async def _rc_run_async(self): def _rc_stop(self): # Note: is only called when we're inside _rc_run self._should_stop = True - self._event.set() + self._queue.put(lambda: None) # trigger an iter def _rc_add_task(self, async_func, name): # we use the async adapter with call_later return super()._rc_add_task(async_func, name) def _rc_call_later(self, delay, callback): - now = time.perf_counter() - time_at = now + max(0, delay) - wrapper = CallAtWrapper(time_at, callback) - heapq.heappush(self._queue, wrapper) - self._event.set() + call_later_from_thread(delay, self._queue.put, callback) loop = RawLoop() From 886e6d4e9b59e73bb4e302d09de8d40e01607c11 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 19 Nov 2025 12:12:33 +0100 Subject: [PATCH 24/50] cleanup --- rendercanvas/_coreutils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/rendercanvas/_coreutils.py b/rendercanvas/_coreutils.py index a6132e6a..989889c6 100644 --- a/rendercanvas/_coreutils.py +++ b/rendercanvas/_coreutils.py @@ -119,7 +119,6 @@ def __init__(self): super().__init__() self._queue = queue.SimpleQueue() self._count = 0 - self._shutdown = False self.daemon = True # don't let this thread prevent shutdown self.start() From d7f5ba62d1cde00795013240048ae4936c253fad Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 17 Dec 2025 12:25:08 +0100 Subject: [PATCH 25/50] some textual changes --- rendercanvas/contexts/wgpucontext.py | 33 ++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/rendercanvas/contexts/wgpucontext.py b/rendercanvas/contexts/wgpucontext.py index 745172f3..f174812e 100644 --- a/rendercanvas/contexts/wgpucontext.py +++ b/rendercanvas/contexts/wgpucontext.py @@ -186,7 +186,7 @@ def __init__(self, present_info: dict): # Canvas capabilities. Stored the first time it is obtained self._capabilities = self._get_capabilities() - # The last used texture + # The current texture to render to. Is replaced when the canvas resizes. self._texture = None # A ring-buffer to download the rendered images to the CPU/RAM. The @@ -195,10 +195,10 @@ def __init__(self, present_info: dict): # 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 + # It looks like a single 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 the + # same time. Let's 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 @@ -208,8 +208,10 @@ def _get_capabilities(self): 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 + self._context_texture_usage = wgpu.TextureUsage.COPY_SRC + self._context_buffer_usage = ( + wgpu.BufferUsage.COPY_DST | wgpu.BufferUsage.MAP_READ + ) capabilities = {} @@ -279,7 +281,8 @@ def _configure(self, config: dict): # (re)create downloaders self._downloaders[:] = [ - ImageDownloader(config["device"], self._our_buffer_usage) + ImageDownloader(config["device"], self.context_buffer_usage) + for _ in self._downloaders ] def _unconfigure(self) -> None: @@ -304,7 +307,7 @@ def _get_current_texture(self): label="present", size=(width, height, 1), format=self._config["format"], - usage=self._config["usage"] | self._our_texture_usage, + usage=self._config["usage"] | self._context_texture_usage, ) return self._texture @@ -340,6 +343,18 @@ def _rc_close(self): class ImageDownloader: """A helper class that wraps a copy-buffer to async-download an image from a texture.""" + # Some timings, to put things into perspective: + # + # 1 ms -> 1000 fps + # 10 ms -> 100 fps + # 16 ms -> 64 fps (windows timer precision) + # 33 ms -> 30 fps + # 100 ms -> 10 fps + # + # If we sync-wait with 10ms means the fps is (way) less than 100. + # If we render at 30 fps, and only present right after the next frame is drawn, we introduce a 33ms delay. + # That's why we want to present asynchronously, and present the result as soon as it's available. + def __init__(self, device, buffer_usage): self._device = device self._buffer_usage = buffer_usage From 550c7f58822887bc093911851dd2eb48d3c91224 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 24 Dec 2025 14:08:37 +0100 Subject: [PATCH 26/50] bitmap present can go outside of animation frame --- rendercanvas/contexts/wgpucontext.py | 2 +- rendercanvas/qt.py | 34 +++++++++++++++++----------- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/rendercanvas/contexts/wgpucontext.py b/rendercanvas/contexts/wgpucontext.py index f174812e..bd8cfb60 100644 --- a/rendercanvas/contexts/wgpucontext.py +++ b/rendercanvas/contexts/wgpucontext.py @@ -281,7 +281,7 @@ def _configure(self, config: dict): # (re)create downloaders self._downloaders[:] = [ - ImageDownloader(config["device"], self.context_buffer_usage) + ImageDownloader(config["device"], self._context_buffer_usage) for _ in self._downloaders ] diff --git a/rendercanvas/qt.py b/rendercanvas/qt.py index 5d92ab6b..226b324c 100644 --- a/rendercanvas/qt.py +++ b/rendercanvas/qt.py @@ -283,6 +283,7 @@ def __init__(self, *args, present_method=None, **kwargs): super().__init__(*args, **kwargs) # Determine present method + self._last_image = None self._last_winid = None self._surface_ids = None if not present_method: @@ -357,7 +358,25 @@ def paintEngine(self): # noqa: N802 - this is a Qt method return super().paintEngine() def paintEvent(self, event): # noqa: N802 - this is a Qt method - self._draw_frame_and_present() + if self._present_to_screen: + self._draw_frame_and_present() + else: + self._draw_frame_and_present() # TODO: probably should not call this here eventually + + image = self._last_image + if image is None: + return + + # Prep drawImage rects + rect1 = QtCore.QRect(0, 0, image.width(), image.height()) + rect2 = self.rect() + + # Paint the image. Nearest neighbor interpolation, like the other backends. + painter = QtGui.QPainter(self) + painter.setRenderHints(painter.RenderHint.Antialiasing, False) + painter.setRenderHints(painter.RenderHint.SmoothPixmapTransform, False) + painter.drawImage(rect2, image, rect1) + painter.end() def update(self): # Bypass Qt's mechanics and request a draw so that the scheduling mechanics work as intended. @@ -444,18 +463,7 @@ def _rc_present_bitmap(self, *, data, format, **kwargs): # Wrap the data in a QImage (no copy) qtformat = BITMAP_FORMAT_MAP[format] bytes_per_line = data.strides[0] - image = QtGui.QImage(data, width, height, bytes_per_line, qtformat) - - # Prep drawImage rects - rect1 = QtCore.QRect(0, 0, width, height) - rect2 = self.rect() - - # Paint the image. Nearest neighbor interpolation, like the other backends. - painter = QtGui.QPainter(self) - painter.setRenderHints(painter.RenderHint.Antialiasing, False) - painter.setRenderHints(painter.RenderHint.SmoothPixmapTransform, False) - painter.drawImage(rect2, image, rect1) - painter.end() + self._last_image = QtGui.QImage(data, width, height, bytes_per_line, qtformat) def _rc_set_logical_size(self, width, height): width, height = int(width), int(height) From 5a72bc1ee7f53c95618de0dc9fff6d947815b781 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 24 Dec 2025 22:04:26 +0100 Subject: [PATCH 27/50] It's messy, but its working. Will cleanup after xmas --- rendercanvas/_scheduler.py | 55 ++++++--- rendercanvas/base.py | 160 +++++++++++++++++++++------ rendercanvas/contexts/basecontext.py | 11 ++ rendercanvas/contexts/wgpucontext.py | 36 +++--- rendercanvas/glfw.py | 11 +- rendercanvas/jupyter.py | 6 +- rendercanvas/offscreen.py | 6 +- rendercanvas/pyodide.py | 8 +- rendercanvas/qt.py | 13 +-- rendercanvas/stub.py | 6 +- rendercanvas/wx.py | 4 +- tests/test_base.py | 20 ++-- tests/test_loop.py | 4 +- tests/test_scheduling.py | 8 +- tests/test_sniffio.py | 4 +- 15 files changed, 239 insertions(+), 113 deletions(-) diff --git a/rendercanvas/_scheduler.py b/rendercanvas/_scheduler.py index 5e0be96a..6b2b367a 100644 --- a/rendercanvas/_scheduler.py +++ b/rendercanvas/_scheduler.py @@ -53,7 +53,8 @@ def __init__( # Scheduling variables self.set_update_mode(update_mode, min_fps=min_fps, max_fps=max_fps) self._draw_requested = True # Start with a draw in ondemand mode - self._async_draw_event = None + # self._async_draw_event = None + self._ready_for_present = None # Keep track of fps self._draw_stats = 0, time.perf_counter() @@ -163,18 +164,37 @@ async def __scheduler_task(self): # size has changed since the last time that events were processed. canvas._process_events() - if not do_draw: - # If we don't want to draw, move to the next iter - del canvas - continue - else: - # Otherwise, request a draw ... - canvas._rc_request_draw() - del canvas - # ... and wait for the draw to happen - self._async_draw_event = Event() - await self._async_draw_event.wait() - last_draw_time = time.perf_counter() + if do_draw: + # We do a draw and wait for the full draw, including the + # presentation, i.e. the 'consumption' of the frame, using an + # event. This async-wait does not do much for the 'screen' + # present method, but for bitmap presenting is makes a huge + # difference, because the CPU can do other things while the GPU + # is downloading the frame. Benchmarks with the simple cube + # example indicate that its over twice as fast as sync-waiting. + # + # We could play with the positioning of where we wait for the + # event. E.g. we could draw the current frame and *then* wait + # for the previous frame to be presented, before initiating the + # presentation of the current frame. Perhaps counterintuitively, + # for the cube example on my M1, this is *not* faster! TODO: + # also try on other hardware A benefit of waiting for the + # presentation to be fully done before even processing the + # events for the next frame, is that the frame is as fresh as it + # can be, which means that for cases where the FPS is low + # because of a slow remote connection, the latency is minimized. + # There is a chance though, that for cases where drawing is + # relatively slow, allowing the draw earlier can improve the FPS + # somewhat. Interestingly, you only want this if the FPS is + # already not low, to avoid latency, but then the FPS is maybe + # already high enough ... + + self._ready_for_present = Event() + canvas._initiate_draw() + canvas._initiate_present() # todo: this was split in two to allow waiting in between. Do we want to keep that option, or simplify the code? + await self._ready_for_present.wait() + + del canvas # Note that when the canvas is closed, we may detect it here and break from the loop. # But the task may also be waiting for a draw to happen, or something else. In that case @@ -186,9 +206,12 @@ def on_draw(self): self._draw_requested = False # Keep ticking - if self._async_draw_event: - self._async_draw_event.set() - self._async_draw_event = None + if self._ready_for_present is not None: + self._ready_for_present.set() + self._ready_for_present = None + # if self._async_draw_event: + # self._async_draw_event.set() + # self._async_draw_event = None # Update stats count, last_time = self._draw_stats diff --git a/rendercanvas/base.py b/rendercanvas/base.py index bb04f000..a1a779a2 100644 --- a/rendercanvas/base.py +++ b/rendercanvas/base.py @@ -20,6 +20,8 @@ from ._loop import BaseLoop from ._scheduler import Scheduler from ._coreutils import logger, log_exception +from .utils.asyncs import Event as AsyncEvent + if TYPE_CHECKING: from typing import Callable, Literal, Optional @@ -167,6 +169,7 @@ def __init__( # Events and scheduler self._events = EventEmitter() self.__scheduler = None + self.__last_present_result = None if self._rc_canvas_group is None: pass # No scheduling, not even grouping elif self._rc_canvas_group.get_loop() is None: @@ -473,29 +476,86 @@ def force_draw(self) -> None: if self.__is_drawing: raise RuntimeError("Cannot force a draw while drawing.") self._rc_force_draw() + # TODO: can I keep this? + + def _initiate_draw(self): + """Do a draw if we can. Called from the scheduler.""" + + # TODO: clean up these notes + # Scheduler wait_for_fps -> canvas._maybe_draw() -> canvas._present() + # -> request_animation_frame -> on_animation_frame -> _draw (and not actually _present()) -> ready_for_present.set() + # -> _draw() -> init present() -> ready_for_present.unset() -> present -> ready_for_present.set() + # + # Canvas \----> call_soon -> _draw() -> request_animation_frame -> on_animation_frame -> present() -> ready_for_present.set() + # \---> request_animation_frame -> on_animation_frame -> _draw (and not actually _present()) + + # Wait for the previous draw to be (sufficiently) progressed + # TODO: move the waiting to the present-stage + + if self._canvas_context is None: + pass + elif self._canvas_context.draw_must_be_in_native_animation_frame: + self._rc_request_animation_frame() + else: + self._draw() # todo: call soon? def _draw_frame_and_present(self): - """Draw the frame and present the result. + # Deprecated + raise RuntimeError("_draw_frame_and_present is renamed to _on_animation_frame") + + def _on_animation_frame(self): + """Called by the backend in an animation frame. - Errors are logged to the "rendercanvas" logger. Should be called by the - subclass at its draw event. + From a scheduling perspective, if this is called, a frame is 'consumed' by the backend. + It means that once we get here, we know + + Errors are logged to the "rendercanvas" logger. """ - # Re-entrent drawing is problematic. Let's actively prevent it. + # Re-entrant drawing is problematic. Let's actively prevent it. if self.__is_drawing: return self.__is_drawing = True + try: + if self._canvas_context: # todo: and else? + if self._canvas_context.draw_must_be_in_native_animation_frame: + # todo: can/should we detect whether a draw was already done, so we also draw if somehow this is called without draw being called first? + self._draw() + self._present() + self._finish_present() + else: + self._finish_present() + finally: + self.__is_drawing = False + + def _initiate_present(self): + if self._canvas_context is None: + pass + elif self._canvas_context.draw_must_be_in_native_animation_frame: + pass # done from _on_animation_frame + else: + self._present() + + def _draw(self): + """Draw the frame.""" + + # Re-entrant drawing is problematic. Let's actively prevent it. + # if self.__is_drawing: + # return + # self.__is_drawing = True + try: # This method is called from the GUI layer. It can be called from a # "draw event" that we requested, or as part of a forced draw. # Cannot draw to a closed canvas. + # todo: these checks ... need also in present-xxx I suppose? if self._rc_get_closed() or self._draw_frame is None: return # Note: could check whether the known physical size is > 0. - # But we also consider it the responsiblity of the backend to not + # But we also consider it the responsibility of the backend to not # draw if the size is zero. GUI toolkits like Qt do this correctly. # I might get back on this once we also draw outside of the draw-event ... @@ -505,40 +565,68 @@ def _draw_frame_and_present(self): # Emit before-draw self._events.emit({"event_type": "before_draw"}) - # Notify the scheduler - if self.__scheduler is not None: - frame_time = self.__scheduler.on_draw() - - # Maybe update title - if frame_time is not None: - self.__title_info["fps"] = f"{min(9999, 1 / frame_time):0.1f}" - self.__title_info["ms"] = f"{min(9999, 1000 * frame_time):0.1f}" - raw_title = self.__title_info["raw"] - if "$fps" in raw_title or "$ms" in raw_title: - self.set_title(self.__title_info["raw"]) - # Perform the user-defined drawing code. When this errors, # we should report the error and then continue, otherwise we crash. with log_exception("Draw error"): self._draw_frame() - with log_exception("Present error"): + + finally: + pass # self.__is_drawing = False + + def _present(self): + # Start the presentation process. + context = self._canvas_context + if context: + with log_exception("Present init error"): # Note: we use canvas._canvas_context, so that if the draw_frame is a stub we also dont trigger creating a context. # Note: if vsync is used, this call may wait a little (happens down at the level of the driver or OS) - context = self._canvas_context - if context: - result = context._rc_present() - method = result.pop("method") - if method in ("skip", "screen"): - pass # nothing we need to do - elif method == "fail": - raise RuntimeError(result.get("message", "") or "present error") - else: - # Pass the result to the literal present method - func = getattr(self, f"_rc_present_{method}") - func(**result) + result = context._rc_present() + + if result["method"] == "async": + + def finish(result): + self.__last_present_result = result + self._rc_request_animation_frame() + + awaitable = result["awaitable"] + awaitable.then(finish) + else: + self.__last_present_result = result + # result = context._rc_present(callback) + # if context.draw_must_be_in_native_animation_frame: + # assert result is not None + # finalize_present(result) + + def _finish_present(self): + result = self.__last_present_result + if result is None: + return + self.__last_present_result = None + + # Callback for the context to finalize the presentation. + # This either gets called from _rc_present, either directly, or via a promise.then() + method = result.pop("method", "unknown") + with log_exception("Present finish error"): + if method in ("skip", "screen"): + pass # nothing we need to do + elif method == "fail": + raise RuntimeError(result.get("message", "") or "present error") + else: + # Pass the result to the literal present method + func = getattr(self, f"_rc_present_{method}") + func(**result) - finally: - self.__is_drawing = False + # Notify the scheduler + if self.__scheduler is not None: + frame_time = self.__scheduler.on_draw() + + # Maybe update title + if frame_time is not None: + self.__title_info["fps"] = f"{min(9999, 1 / frame_time):0.1f}" + self.__title_info["ms"] = f"{min(9999, 1000 * frame_time):0.1f}" + raw_title = self.__title_info["raw"] + if "$fps" in raw_title or "$ms" in raw_title: + self.set_title(self.__title_info["raw"]) # %% Primary canvas management methods @@ -668,11 +756,11 @@ def _rc_get_present_methods(self): """ raise NotImplementedError() - def _rc_request_draw(self): + def _rc_request_animation_frame(self): """Request the GUI layer to perform a draw. Like requestAnimationFrame in JS. The draw must be performed - by calling ``_draw_frame_and_present()``. It's the responsibility + by calling ``_on_animation_frame()``. It's the responsibility for the canvas subclass to make sure that a draw is made as soon as possible. @@ -685,9 +773,9 @@ def _rc_force_draw(self): """Perform a synchronous draw. When it returns, the draw must have been done. - The default implementation just calls ``_draw_frame_and_present()``. + The default implementation just calls ``_on_animation_frame()``. """ - self._draw_frame_and_present() + self._on_animation_frame() def _rc_present_bitmap(self, *, data, format, **kwargs): """Present the given image bitmap. Only used with present_method 'bitmap'. diff --git a/rendercanvas/contexts/basecontext.py b/rendercanvas/contexts/basecontext.py index d880d9c3..650753fe 100644 --- a/rendercanvas/contexts/basecontext.py +++ b/rendercanvas/contexts/basecontext.py @@ -9,6 +9,17 @@ class BaseContext: # Subclasses must define their present-methods that they support, in oder of preference present_methods = [] + # Whether drawing must occur in the backend's native animation frame. + # Applies to WgpuContextToScreen, i.e. rendering to a Qt widget or a + # browser's . The main reason is the context.get_current_texture() + # call that's done during the draw, which these systems need to align with + # the native drawing cycle. If this is False, the present step can be + # separated from the draw, which is important for e.g. the + # WgpuContextToBitmap because then it can async-download the bitmap. + draw_must_be_in_native_animation_frame = ( + False # TODO: actually this is just present_method == 'screen'! + ) + def __init__(self, present_info: dict): self._present_info = present_info assert present_info["method"] in ("bitmap", "screen") # internal sanity check diff --git a/rendercanvas/contexts/wgpucontext.py b/rendercanvas/contexts/wgpucontext.py index bd8cfb60..ebfef462 100644 --- a/rendercanvas/contexts/wgpucontext.py +++ b/rendercanvas/contexts/wgpucontext.py @@ -126,7 +126,7 @@ def get_current_texture(self) -> object: def _get_current_texture(self): raise NotImplementedError() - def _rc_present(self) -> None: + def _rc_present(self, callback) -> None: """Hook for the canvas to present the rendered result. Present what has been drawn to the current texture, by compositing it to the @@ -144,6 +144,8 @@ class WgpuContextToScreen(WgpuContext): present_methods = ["screen"] + draw_must_be_in_native_animation_frame = True + def __init__(self, present_info: dict): super().__init__(present_info) assert self._present_info["method"] == "screen" @@ -318,23 +320,28 @@ def _rc_present(self) -> None: # TODO: in some cases, like offscreen backend, we don't want to skip the first frame! - # Get bitmap from oldest downloader - bitmap = None - downloader = self._downloaders.pop(0) - try: - bitmap = downloader.get_bitmap() - finally: - self._downloaders.append(downloader) + # # Get bitmap from oldest downloader + # bitmap = None + # downloader = self._downloaders.pop(0) + # try: + # bitmap = downloader.get_bitmap() + # finally: + # self._downloaders.append(downloader) + + def resolver(buf): + bitmap = downloader.get_bitmap() # todo: read from mapped buffer instead? or have an awaitable that returns memory + if bitmap is None: + return {"method": "skip"} + else: + return {"method": "bitmap", "format": "rgba-u8", "data": bitmap} # Select new downloader downloader = self._downloaders[-1] - downloader.initiate_download(self._texture) + awaitable = downloader.initiate_download(self._texture).then(resolver) - self._drop_texture() - if bitmap is None: - return {"method": "skip"} - else: - return {"method": "bitmap", "format": "rgba-u8", "data": bitmap} + return {"method": "async", "awaitable": awaitable} + + # downloader._awaitable def _rc_close(self): self._drop_texture() @@ -372,6 +379,7 @@ def initiate_download(self, 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) + return self._awaitable def _parse_texture_metadata(self, texture): size = texture.size diff --git a/rendercanvas/glfw.py b/rendercanvas/glfw.py index 01537875..1c15aeab 100644 --- a/rendercanvas/glfw.py +++ b/rendercanvas/glfw.py @@ -258,7 +258,7 @@ def _on_window_dirty(self, *args): def _on_iconify(self, window, iconified): self._is_minimized = bool(iconified) if not self._is_minimized: - self._rc_request_draw() + self._rc_request_animation_frame() def _determine_size(self): if self._window is None: @@ -321,13 +321,13 @@ def _rc_gui_poll(self): def _rc_get_present_methods(self): return get_glfw_present_methods(self._window) - def _rc_request_draw(self): + def _rc_request_animation_frame(self): if not self._is_minimized: loop = self._rc_canvas_group.get_loop() - loop.call_soon(self._draw_frame_and_present) + loop.call_soon(self._on_animation_frame) def _rc_force_draw(self): - self._draw_frame_and_present() + self._on_animation_frame() def _rc_present_bitmap(self, **kwargs): raise NotImplementedError() @@ -401,7 +401,8 @@ def _on_size_change(self, *args): # that rely on the event-loop are paused (only animations # updated in the draw callback are alive). if self._is_in_poll_events and not self._is_minimized: - self._draw_frame_and_present() + # todo: need also force draw? + self._on_animation_frame() def _on_mouse_button(self, window, but, action, mods): # Map button being changed, which we use to update self._pointer_buttons. diff --git a/rendercanvas/jupyter.py b/rendercanvas/jupyter.py index 44eda921..2194e637 100644 --- a/rendercanvas/jupyter.py +++ b/rendercanvas/jupyter.py @@ -37,11 +37,11 @@ def __init__(self, *args, **kwargs): self._final_canvas_init() def get_frame(self): - # The _draw_frame_and_present() does the drawing and then calls + # The _on_animation_frame() does the drawing and then calls # present_context.present(), which calls our present() method. # The result is either a numpy array or None, and this matches # with what this method is expected to return. - self._draw_frame_and_present() + self._on_animation_frame() return self._last_image # %% Methods to implement RenderCanvas @@ -61,7 +61,7 @@ def _rc_get_present_methods(self): } } - def _rc_request_draw(self): + def _rc_request_animation_frame(self): self._draw_request_time = time.perf_counter() RemoteFrameBuffer.request_draw(self) diff --git a/rendercanvas/offscreen.py b/rendercanvas/offscreen.py index 77748b51..5b59af00 100644 --- a/rendercanvas/offscreen.py +++ b/rendercanvas/offscreen.py @@ -59,13 +59,13 @@ def _rc_get_present_methods(self): } } - def _rc_request_draw(self): + def _rc_request_animation_frame(self): # Ok, cool, the scheduler want a draw. But we only draw when the user # calls draw(), so that's how this canvas ticks. pass def _rc_force_draw(self): - self._draw_frame_and_present() + self._on_animation_frame() def _rc_present_bitmap(self, *, data, format, **kwargs): self._last_image = data @@ -119,7 +119,7 @@ def draw(self): This object can be converted to a numpy array (without copying data) using ``np.asarray(arr)``. """ - self._draw_frame_and_present() + self._on_animation_frame() return self._last_image diff --git a/rendercanvas/pyodide.py b/rendercanvas/pyodide.py index 7c2723b8..27e0467b 100644 --- a/rendercanvas/pyodide.py +++ b/rendercanvas/pyodide.py @@ -440,16 +440,14 @@ def _rc_get_present_methods(self): }, } - def _rc_request_draw(self): - window.requestAnimationFrame( - create_proxy(lambda _: self._draw_frame_and_present()) - ) + def _rc_request_animation_frame(self): + window.requestAnimationFrame(create_proxy(lambda _: self._on_animation_frame())) def _rc_force_draw(self): # Not very clean to do this, and not sure if it works in a browser; # you can draw all you want, but the browser compositer only uses the last frame, I expect. # But that's ok, since force-drawing is not recommended in general. - self._draw_frame_and_present() + self._on_animation_frame() def _rc_present_bitmap(self, **kwargs): data = kwargs.get("data") diff --git a/rendercanvas/qt.py b/rendercanvas/qt.py index 226b324c..d07fc5d7 100644 --- a/rendercanvas/qt.py +++ b/rendercanvas/qt.py @@ -284,6 +284,7 @@ def __init__(self, *args, present_method=None, **kwargs): # Determine present method self._last_image = None + self._image_count = 0 self._last_winid = None self._surface_ids = None if not present_method: @@ -358,14 +359,9 @@ def paintEngine(self): # noqa: N802 - this is a Qt method return super().paintEngine() def paintEvent(self, event): # noqa: N802 - this is a Qt method - if self._present_to_screen: - self._draw_frame_and_present() - else: - self._draw_frame_and_present() # TODO: probably should not call this here eventually - + self._on_animation_frame() + if self._last_image is not None: image = self._last_image - if image is None: - return # Prep drawImage rects rect1 = QtCore.QRect(0, 0, image.width(), image.height()) @@ -414,7 +410,7 @@ def _rc_get_present_methods(self): methods["bitmap"] = {"formats": list(BITMAP_FORMAT_MAP.keys())} return methods - def _rc_request_draw(self): + def _rc_request_animation_frame(self): # Ask Qt to do a paint event QtWidgets.QWidget.update(self) @@ -464,6 +460,7 @@ def _rc_present_bitmap(self, *, data, format, **kwargs): qtformat = BITMAP_FORMAT_MAP[format] bytes_per_line = data.strides[0] self._last_image = QtGui.QImage(data, width, height, bytes_per_line, qtformat) + self._image_count += 1 def _rc_set_logical_size(self, width, height): width, height = int(width), int(height) diff --git a/rendercanvas/stub.py b/rendercanvas/stub.py index e9d81474..d9aa9b7d 100644 --- a/rendercanvas/stub.py +++ b/rendercanvas/stub.py @@ -68,7 +68,7 @@ class StubRenderCanvas(BaseRenderCanvas): Backends must call ``self._final_canvas_init()`` at the end of its ``__init__()``. This will set the canvas' logical size and title. - Backends must call ``self._draw_frame_and_present()`` to make the actual + Backends must call ``self._on_animation_frame()`` to make the actual draw. This should typically be done inside the backend's native draw event. Backends must call ``self._size_info.set_physical_size(width, height, native_pixel_ratio)``, @@ -91,11 +91,11 @@ def _rc_gui_poll(self): def _rc_get_present_methods(self): raise NotImplementedError() - def _rc_request_draw(self): + def _rc_request_animation_frame(self): pass def _rc_force_draw(self): - self._draw_frame_and_present() + self._on_animation_frame() def _rc_present_bitmap(self, *, data, format, **kwargs): raise NotImplementedError() diff --git a/rendercanvas/wx.py b/rendercanvas/wx.py index 0609c918..14e6b8a3 100644 --- a/rendercanvas/wx.py +++ b/rendercanvas/wx.py @@ -267,7 +267,7 @@ def _on_resize_done(self, *args): def on_paint(self, event): dc = wx.PaintDC(self) # needed for wx if not self._draw_lock: - self._draw_frame_and_present() + self._on_animation_frame() del dc event.Skip() @@ -325,7 +325,7 @@ def _rc_get_present_methods(self): methods["bitmap"] = {"formats": ["rgba-u8"]} return methods - def _rc_request_draw(self): + def _rc_request_animation_frame(self): if self._draw_lock: return try: diff --git a/tests/test_base.py b/tests/test_base.py index 74da38aa..2ed6ade9 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -43,10 +43,10 @@ def test_canvas_logging(caplog): canvas = CanvasThatRaisesErrorsDuringDrawing() - canvas._draw_frame_and_present() # prints traceback - canvas._draw_frame_and_present() # prints short logs ... - canvas._draw_frame_and_present() - canvas._draw_frame_and_present() + canvas._on_animation_frame() # prints traceback + canvas._on_animation_frame() # prints short logs ... + canvas._on_animation_frame() + canvas._on_animation_frame() text = caplog.text assert text.count("bar_method") == 2 # one traceback => 2 mentions @@ -58,10 +58,10 @@ def test_canvas_logging(caplog): assert text.count("spam_method") == 0 assert text.count("intended-fail") == 0 - canvas._draw_frame_and_present() # prints traceback - canvas._draw_frame_and_present() # prints short logs ... - canvas._draw_frame_and_present() - canvas._draw_frame_and_present() + canvas._on_animation_frame() # prints traceback + canvas._on_animation_frame() # prints short logs ... + canvas._on_animation_frame() + canvas._on_animation_frame() text = caplog.text assert text.count("bar_method") == 2 # one traceback => 2 mentions @@ -100,10 +100,10 @@ def test_run_bare_canvas(): # canvas = RenderCanvas() # loop.run() # - # Note: loop.run() calls _draw_frame_and_present() in event loop. + # Note: loop.run() calls _on_animation_frame() in event loop. canvas = MyOffscreenCanvas() - canvas._draw_frame_and_present() + canvas._on_animation_frame() @mark.skipif(not can_use_wgpu_lib, reason="Needs wgpu lib") diff --git a/tests/test_loop.py b/tests/test_loop.py index c05d5e96..94dbb0a8 100644 --- a/tests/test_loop.py +++ b/tests/test_loop.py @@ -159,9 +159,9 @@ def _rc_close(self): def _rc_get_closed(self): return self._is_closed - def _rc_request_draw(self): + def _rc_request_animation_frame(self): loop = self._rc_canvas_group.get_loop() - loop.call_soon(self._draw_frame_and_present) + loop.call_soon(self._on_animation_frame) # %%%%% deleting loops diff --git a/tests/test_scheduling.py b/tests/test_scheduling.py index f2688dd1..bd1c240f 100644 --- a/tests/test_scheduling.py +++ b/tests/test_scheduling.py @@ -42,17 +42,17 @@ def _process_events(self): self.events_count += 1 return super()._process_events() - def _draw_frame_and_present(self): - super()._draw_frame_and_present() + def _on_animation_frame(self): + super()._on_animation_frame() self.draw_count += 1 - def _rc_request_draw(self): + def _rc_request_animation_frame(self): self._gui_draw_requested = True def draw_if_necessary(self): if self._gui_draw_requested: self._gui_draw_requested = False - self._draw_frame_and_present() + self._on_animation_frame() def active_sleep(self, delay): loop = self._rc_canvas_group.get_loop() # <---- diff --git a/tests/test_sniffio.py b/tests/test_sniffio.py index cd8c4be5..de2c1399 100644 --- a/tests/test_sniffio.py +++ b/tests/test_sniffio.py @@ -35,9 +35,9 @@ def _rc_close(self): def _rc_get_closed(self): return self._is_closed - def _rc_request_draw(self): + def _rc_request_animation_frame(self): loop = self._rc_canvas_group.get_loop() - loop.call_soon(self._draw_frame_and_present) + loop.call_soon(self._on_animation_frame) def get_sniffio_name(): From 8d8c9c2225a66a676cfda3948fb7ccc19bc8fbe9 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 9 Jan 2026 13:44:43 +0100 Subject: [PATCH 28/50] Minor cleanup --- rendercanvas/_scheduler.py | 34 ++++----- rendercanvas/base.py | 107 ++++++++++++++------------- rendercanvas/contexts/basecontext.py | 8 +- rendercanvas/contexts/wgpucontext.py | 9 ++- rendercanvas/qt.py | 11 +-- 5 files changed, 84 insertions(+), 85 deletions(-) diff --git a/rendercanvas/_scheduler.py b/rendercanvas/_scheduler.py index 6b2b367a..d9ad32e8 100644 --- a/rendercanvas/_scheduler.py +++ b/rendercanvas/_scheduler.py @@ -53,7 +53,6 @@ def __init__( # Scheduling variables self.set_update_mode(update_mode, min_fps=min_fps, max_fps=max_fps) self._draw_requested = True # Start with a draw in ondemand mode - # self._async_draw_event = None self._ready_for_present = None # Keep track of fps @@ -165,9 +164,9 @@ async def __scheduler_task(self): canvas._process_events() if do_draw: - # We do a draw and wait for the full draw, including the - # presentation, i.e. the 'consumption' of the frame, using an - # event. This async-wait does not do much for the 'screen' + # We do a draw and wait for the full draw to complete, including + # the presentation (i.e. the 'consumption' of the frame), using + # an event. This async-wait does not do much for the 'screen' # present method, but for bitmap presenting is makes a huge # difference, because the CPU can do other things while the GPU # is downloading the frame. Benchmarks with the simple cube @@ -177,18 +176,18 @@ async def __scheduler_task(self): # event. E.g. we could draw the current frame and *then* wait # for the previous frame to be presented, before initiating the # presentation of the current frame. Perhaps counterintuitively, - # for the cube example on my M1, this is *not* faster! TODO: - # also try on other hardware A benefit of waiting for the - # presentation to be fully done before even processing the - # events for the next frame, is that the frame is as fresh as it - # can be, which means that for cases where the FPS is low - # because of a slow remote connection, the latency is minimized. - # There is a chance though, that for cases where drawing is - # relatively slow, allowing the draw earlier can improve the FPS - # somewhat. Interestingly, you only want this if the FPS is - # already not low, to avoid latency, but then the FPS is maybe - # already high enough ... - + # for the cube example on my M1, this is *not* faster! A benefit + # of waiting for the presentation to be fully done before even + # processing the events for the next frame, is that the frame is + # as fresh as it can be, which means that for cases where the + # FPS is low because of a slow remote connection, the latency is + # minimized. There is a chance though, that for cases where + # drawing is relatively slow, allowing the draw earlier can + # improve the FPS somewhat. Interestingly, you only want this if + # the FPS is already not low, to avoid latency, but then the FPS + # is maybe already high enough ... + + # TODO: also try on other hardware whether this is fastest self._ready_for_present = Event() canvas._initiate_draw() canvas._initiate_present() # todo: this was split in two to allow waiting in between. Do we want to keep that option, or simplify the code? @@ -209,9 +208,6 @@ def on_draw(self): if self._ready_for_present is not None: self._ready_for_present.set() self._ready_for_present = None - # if self._async_draw_event: - # self._async_draw_event.set() - # self._async_draw_event = None # Update stats count, last_time = self._draw_stats diff --git a/rendercanvas/base.py b/rendercanvas/base.py index a1a779a2..ddbd6acf 100644 --- a/rendercanvas/base.py +++ b/rendercanvas/base.py @@ -20,7 +20,6 @@ from ._loop import BaseLoop from ._scheduler import Scheduler from ._coreutils import logger, log_exception -from .utils.asyncs import Event as AsyncEvent if TYPE_CHECKING: @@ -479,25 +478,40 @@ def force_draw(self) -> None: # TODO: can I keep this? def _initiate_draw(self): - """Do a draw if we can. Called from the scheduler.""" + """Initiate drawing the next frame. Called from the scheduler. - # TODO: clean up these notes - # Scheduler wait_for_fps -> canvas._maybe_draw() -> canvas._present() - # -> request_animation_frame -> on_animation_frame -> _draw (and not actually _present()) -> ready_for_present.set() - # -> _draw() -> init present() -> ready_for_present.unset() -> present -> ready_for_present.set() - # - # Canvas \----> call_soon -> _draw() -> request_animation_frame -> on_animation_frame -> present() -> ready_for_present.set() - # \---> request_animation_frame -> on_animation_frame -> _draw (and not actually _present()) + This may draw right now, or schedule the drawing, depending on the context's requirements. + Together with `_initiate_present` the methods `_draw`, `_present` and `_finish_present` should all be called. - # Wait for the previous draw to be (sufficiently) progressed - # TODO: move the waiting to the present-stage + The screen path: + + _initiate_draw() -> _rc_request_animation_frame() -> ... -> _on_animation_frame() -> _draw() _present() _finish_present() + _initiate_present() -> noop + + The bitmap path: + + _initiate_draw() -> _draw() + _initiate_present() -> _present() -> _rc_request_animation_frame() -> ... -> _on_animation_frame() -> _finish_present() + """ if self._canvas_context is None: pass - elif self._canvas_context.draw_must_be_in_native_animation_frame: + elif self._canvas_context.draw_must_be_in_animation_frame: self._rc_request_animation_frame() else: - self._draw() # todo: call soon? + self._draw() + + def _initiate_present(self): + """Initiate presenting the most recent drawn frame. Called from the scheduler. + + See `_initiate_draw` for details. + """ + if self._canvas_context is None: + pass + elif self._canvas_context.draw_must_be_in_animation_frame: + pass # done from _on_animation_frame + else: + self._present() def _draw_frame_and_present(self): # Deprecated @@ -506,8 +520,7 @@ def _draw_frame_and_present(self): def _on_animation_frame(self): """Called by the backend in an animation frame. - From a scheduling perspective, if this is called, a frame is 'consumed' by the backend. - It means that once we get here, we know + From a scheduling perspective, when this is called, a frame is 'consumed' by the backend. Errors are logged to the "rendercanvas" logger. """ @@ -519,7 +532,7 @@ def _on_animation_frame(self): try: if self._canvas_context: # todo: and else? - if self._canvas_context.draw_must_be_in_native_animation_frame: + if self._canvas_context.draw_must_be_in_animation_frame: # todo: can/should we detect whether a draw was already done, so we also draw if somehow this is called without draw being called first? self._draw() self._present() @@ -529,51 +542,36 @@ def _on_animation_frame(self): finally: self.__is_drawing = False - def _initiate_present(self): - if self._canvas_context is None: - pass - elif self._canvas_context.draw_must_be_in_native_animation_frame: - pass # done from _on_animation_frame - else: - self._present() - def _draw(self): """Draw the frame.""" - # Re-entrant drawing is problematic. Let's actively prevent it. - # if self.__is_drawing: - # return - # self.__is_drawing = True - - try: - # This method is called from the GUI layer. It can be called from a - # "draw event" that we requested, or as part of a forced draw. + # Cannot draw to a closed canvas. + # todo: these checks ... need also in present-xxx I suppose? + if self._rc_get_closed() or self._draw_frame is None: + return - # Cannot draw to a closed canvas. - # todo: these checks ... need also in present-xxx I suppose? - if self._rc_get_closed() or self._draw_frame is None: - return + # Note: could check whether the known physical size is > 0. + # But we also consider it the responsibility of the backend to not + # draw if the size is zero. GUI toolkits like Qt do this correctly. + # I might get back on this once we also draw outside of the draw-event ... - # Note: could check whether the known physical size is > 0. - # But we also consider it the responsibility of the backend to not - # draw if the size is zero. GUI toolkits like Qt do this correctly. - # I might get back on this once we also draw outside of the draw-event ... + # Make sure that the user-code is up-to-date with the current size before it draws. + self.__maybe_emit_resize_event() - # Make sure that the user-code is up-to-date with the current size before it draws. - self.__maybe_emit_resize_event() + # Emit before-draw + self._events.emit({"event_type": "before_draw"}) - # Emit before-draw - self._events.emit({"event_type": "before_draw"}) + # Perform the user-defined drawing code. When this errors, + # we should report the error and then continue, otherwise we crash. + with log_exception("Draw error"): + self._draw_frame() - # Perform the user-defined drawing code. When this errors, - # we should report the error and then continue, otherwise we crash. - with log_exception("Draw error"): - self._draw_frame() + def _present(self): + """Present the frame. Can be direct or async. - finally: - pass # self.__is_drawing = False + Need a call to _finish_present to finalize the presentation. + """ - def _present(self): # Start the presentation process. context = self._canvas_context if context: @@ -586,18 +584,21 @@ def _present(self): def finish(result): self.__last_present_result = result - self._rc_request_animation_frame() + self._rc_request_animation_frame() # eventually calls _finish_present() awaitable = result["awaitable"] awaitable.then(finish) else: self.__last_present_result = result + # todo: can we do some check so we know that _finish_present() is called? # result = context._rc_present(callback) - # if context.draw_must_be_in_native_animation_frame: + # if context.draw_must_be_in_animation_frame: # assert result is not None # finalize_present(result) def _finish_present(self): + """Wrap up the presentation process.""" + result = self.__last_present_result if result is None: return diff --git a/rendercanvas/contexts/basecontext.py b/rendercanvas/contexts/basecontext.py index 650753fe..591a8e1f 100644 --- a/rendercanvas/contexts/basecontext.py +++ b/rendercanvas/contexts/basecontext.py @@ -13,10 +13,10 @@ class BaseContext: # Applies to WgpuContextToScreen, i.e. rendering to a Qt widget or a # browser's . The main reason is the context.get_current_texture() # call that's done during the draw, which these systems need to align with - # the native drawing cycle. If this is False, the present step can be - # separated from the draw, which is important for e.g. the - # WgpuContextToBitmap because then it can async-download the bitmap. - draw_must_be_in_native_animation_frame = ( + # the native drawing cycle. If this is False, the draw step can be separated + # from the present, which is important for the WgpuContextToBitmap to + # async-download the bitmap. + draw_must_be_in_animation_frame = ( False # TODO: actually this is just present_method == 'screen'! ) diff --git a/rendercanvas/contexts/wgpucontext.py b/rendercanvas/contexts/wgpucontext.py index ebfef462..d818376b 100644 --- a/rendercanvas/contexts/wgpucontext.py +++ b/rendercanvas/contexts/wgpucontext.py @@ -126,7 +126,7 @@ def get_current_texture(self) -> object: def _get_current_texture(self): raise NotImplementedError() - def _rc_present(self, callback) -> None: + def _rc_present(self) -> None: """Hook for the canvas to present the rendered result. Present what has been drawn to the current texture, by compositing it to the @@ -144,7 +144,7 @@ class WgpuContextToScreen(WgpuContext): present_methods = ["screen"] - draw_must_be_in_native_animation_frame = True + draw_must_be_in_animation_frame = True def __init__(self, present_info: dict): super().__init__(present_info) @@ -202,6 +202,7 @@ def __init__(self, present_info: dict): # GPU more, because it would be busy mapping multiple buffers at the # same time. Let's leave the ring-mechanism in-place for now, so we can # experiment with it. + # TODO: refactor to just one downloader, making the code a bit simpler self._downloaders = [None] # Put as many None's as you want buffers def _get_capabilities(self): @@ -506,11 +507,11 @@ def get_bitmap(self): # data.shape = -1 # data = memoryview(data) + # TODO: can we pass the mapped data downstream without copying it, i.e. before unmapping the buffer? Saves another copy. + # Since we use read_mapped(copy=False), we must unmap it *after* we've copied the data. self._buffer.unmap() - # Derive struct dtype from wgpu texture format - # Represent as memory object to avoid numpy dependency # Equivalent: np.frombuffer(data, np.uint8).reshape(plain_shape) data = data.cast(memoryview_type, plain_shape) diff --git a/rendercanvas/qt.py b/rendercanvas/qt.py index d07fc5d7..bda9b7d6 100644 --- a/rendercanvas/qt.py +++ b/rendercanvas/qt.py @@ -284,7 +284,6 @@ def __init__(self, *args, present_method=None, **kwargs): # Determine present method self._last_image = None - self._image_count = 0 self._last_winid = None self._surface_ids = None if not present_method: @@ -361,7 +360,7 @@ def paintEngine(self): # noqa: N802 - this is a Qt method def paintEvent(self, event): # noqa: N802 - this is a Qt method self._on_animation_frame() if self._last_image is not None: - image = self._last_image + image = self._last_image[0] # Prep drawImage rects rect1 = QtCore.QRect(0, 0, image.width(), image.height()) @@ -456,11 +455,13 @@ def _rc_present_bitmap(self, *, data, format, **kwargs): width, height = data.shape[1], data.shape[0] # width, height - # Wrap the data in a QImage (no copy) + # Wrap the data in a QImage (no copy, so we need to keep a ref to data) qtformat = BITMAP_FORMAT_MAP[format] bytes_per_line = data.strides[0] - self._last_image = QtGui.QImage(data, width, height, bytes_per_line, qtformat) - self._image_count += 1 + self._last_image = ( + QtGui.QImage(data, width, height, bytes_per_line, qtformat), + data, + ) def _rc_set_logical_size(self, width, height): width, height = int(width), int(height) From fe99943c1e0d40059fc272b2216a291aa001e155 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 9 Jan 2026 15:29:03 +0100 Subject: [PATCH 29/50] comment --- rendercanvas/_scheduler.py | 39 ++++++++++++++++++++------------------ 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/rendercanvas/_scheduler.py b/rendercanvas/_scheduler.py index d9ad32e8..b40955b5 100644 --- a/rendercanvas/_scheduler.py +++ b/rendercanvas/_scheduler.py @@ -166,28 +166,31 @@ async def __scheduler_task(self): if do_draw: # We do a draw and wait for the full draw to complete, including # the presentation (i.e. the 'consumption' of the frame), using - # an event. This async-wait does not do much for the 'screen' - # present method, but for bitmap presenting is makes a huge - # difference, because the CPU can do other things while the GPU - # is downloading the frame. Benchmarks with the simple cube - # example indicate that its over twice as fast as sync-waiting. + # an async-event. This async-wait does not do much for the + # 'screen' present method, but for bitmap presenting is makes a + # huge difference, because the CPU can do other things while the + # GPU is downloading the frame. Benchmarks with the simple cube + # example indicate that its about twice as fast as sync-waiting. # # We could play with the positioning of where we wait for the # event. E.g. we could draw the current frame and *then* wait # for the previous frame to be presented, before initiating the - # presentation of the current frame. Perhaps counterintuitively, - # for the cube example on my M1, this is *not* faster! A benefit - # of waiting for the presentation to be fully done before even - # processing the events for the next frame, is that the frame is - # as fresh as it can be, which means that for cases where the - # FPS is low because of a slow remote connection, the latency is - # minimized. There is a chance though, that for cases where - # drawing is relatively slow, allowing the draw earlier can - # improve the FPS somewhat. Interestingly, you only want this if - # the FPS is already not low, to avoid latency, but then the FPS - # is maybe already high enough ... - - # TODO: also try on other hardware whether this is fastest + # presentation of the current frame. However, this does *not* + # seem to be faster! Tested on MacOS M1 and Windows with NVidia + # on the cube example. + # + # A benefit of waiting for the presentation to be fully done + # before even processing the events for the next frame, is that + # the frame is as fresh as it can be, which means that for cases + # where the FPS is low because of a slow remote connection, the + # latency is minimized. + # + # It is possible that for cases where drawing is relatively + # slow, allowing the draw earlier can improve the FPS somewhat. + # Interestingly, you only want this for relatively high FPS to + # avoid latency. But then the FPS is likely already high enough, + # so why bother. + self._ready_for_present = Event() canvas._initiate_draw() canvas._initiate_present() # todo: this was split in two to allow waiting in between. Do we want to keep that option, or simplify the code? From 85a6de19c2d950ae1ba5df3158dcd782d3f33873 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Mon, 12 Jan 2026 16:18:36 +0100 Subject: [PATCH 30/50] Offscreen.draw is sync, and so is force_draw() --- examples/demo.py | 4 +- rendercanvas/base.py | 54 +++--- rendercanvas/contexts/basecontext.py | 7 +- rendercanvas/contexts/bitmapcontext.py | 4 +- rendercanvas/contexts/wgpucontext.py | 259 ++++++++++++++++--------- rendercanvas/glfw.py | 14 +- rendercanvas/offscreen.py | 5 +- rendercanvas/qt.py | 11 +- 8 files changed, 223 insertions(+), 135 deletions(-) diff --git a/examples/demo.py b/examples/demo.py index f5c28473..08d32936 100644 --- a/examples/demo.py +++ b/examples/demo.py @@ -14,7 +14,7 @@ import time -from rendercanvas.auto import RenderCanvas, loop +from rendercanvas.pyside6 import RenderCanvas, loop from rendercanvas.utils.cube import setup_drawing_sync import rendercanvas @@ -24,7 +24,7 @@ title="Canvas events with $backend on $loop - $fps fps", max_fps=10, update_mode="continuous", - present_method="", + present_method="bitmap", ) diff --git a/rendercanvas/base.py b/rendercanvas/base.py index ddbd6acf..2e862d81 100644 --- a/rendercanvas/base.py +++ b/rendercanvas/base.py @@ -168,7 +168,6 @@ def __init__( # Events and scheduler self._events = EventEmitter() self.__scheduler = None - self.__last_present_result = None if self._rc_canvas_group is None: pass # No scheduling, not even grouping elif self._rc_canvas_group.get_loop() is None: @@ -474,8 +473,12 @@ def force_draw(self) -> None: """ if self.__is_drawing: raise RuntimeError("Cannot force a draw while drawing.") - self._rc_force_draw() - # TODO: can I keep this? + if self._canvas_context.draw_must_be_in_animation_frame: + self._rc_force_draw() + else: + self._draw() + self._present(force_sync=True) + self._rc_force_draw() # May or may not work def _initiate_draw(self): """Initiate drawing the next frame. Called from the scheduler. @@ -491,7 +494,7 @@ def _initiate_draw(self): The bitmap path: _initiate_draw() -> _draw() - _initiate_present() -> _present() -> _rc_request_animation_frame() -> ... -> _on_animation_frame() -> _finish_present() + _initiate_present() -> _present() -> ... -> finish_present() -> _rc_request_animation_frame() """ if self._canvas_context is None: @@ -525,6 +528,11 @@ def _on_animation_frame(self): Errors are logged to the "rendercanvas" logger. """ + if ( + not self._canvas_context.draw_must_be_in_animation_frame + ): # TODO: mmm maybe also with BitmapContextToScreen? + return + # Re-entrant drawing is problematic. Let's actively prevent it. if self.__is_drawing: return @@ -532,13 +540,9 @@ def _on_animation_frame(self): try: if self._canvas_context: # todo: and else? - if self._canvas_context.draw_must_be_in_animation_frame: - # todo: can/should we detect whether a draw was already done, so we also draw if somehow this is called without draw being called first? - self._draw() - self._present() - self._finish_present() - else: - self._finish_present() + # todo: can/should we detect whether a draw was already done, so we also draw if somehow this is called without draw being called first? + self._draw() + self._present() finally: self.__is_drawing = False @@ -566,11 +570,8 @@ def _draw(self): with log_exception("Draw error"): self._draw_frame() - def _present(self): - """Present the frame. Can be direct or async. - - Need a call to _finish_present to finalize the presentation. - """ + def _present(self, *, force_sync=False): + """Present the frame. Can be direct or async.""" # Start the presentation process. context = self._canvas_context @@ -578,32 +579,21 @@ def _present(self): with log_exception("Present init error"): # Note: we use canvas._canvas_context, so that if the draw_frame is a stub we also dont trigger creating a context. # Note: if vsync is used, this call may wait a little (happens down at the level of the driver or OS) - result = context._rc_present() + result = context._rc_present(force_sync=force_sync) if result["method"] == "async": - - def finish(result): - self.__last_present_result = result - self._rc_request_animation_frame() # eventually calls _finish_present() - - awaitable = result["awaitable"] - awaitable.then(finish) + result["awaitable"].then(self._finish_present) else: - self.__last_present_result = result - # todo: can we do some check so we know that _finish_present() is called? + self._finish_present(result) + # result = context._rc_present(callback) # if context.draw_must_be_in_animation_frame: # assert result is not None # finalize_present(result) - def _finish_present(self): + def _finish_present(self, result): """Wrap up the presentation process.""" - result = self.__last_present_result - if result is None: - return - self.__last_present_result = None - # Callback for the context to finalize the presentation. # This either gets called from _rc_present, either directly, or via a promise.then() method = result.pop("method", "unknown") diff --git a/rendercanvas/contexts/basecontext.py b/rendercanvas/contexts/basecontext.py index 591a8e1f..d300fb57 100644 --- a/rendercanvas/contexts/basecontext.py +++ b/rendercanvas/contexts/basecontext.py @@ -99,7 +99,7 @@ def looks_like_hidpi(self) -> bool: """ return self._size_info["native_pixel_ratio"] >= 2.0 - def _rc_present(self): + def _rc_present(self, *, force_sync: bool = False): """Called by BaseRenderCanvas to collect the result. Subclasses must implement this. The implementation should always return a present-result dict, which @@ -109,6 +109,11 @@ def _rc_present(self): * return ``{"method": "skip"}`` (special case). * If presentation could not be done for some reason: * return ``{"method": "fail", "message": "xx"}`` (special case). + * If the presenting is asynchronous: + * Return ``{"method": "async", "awaitable": xx}`` + * The 'awaitable' has a ``then(callback)`` method. + * The callback will be called with the actual result dictionary. + * If ``force_sync`` is True, this is not allowed. * If ``present_method`` is "screen": * Render to screen using the present info. * Return ``{"method", "screen"}`` as confirmation. diff --git a/rendercanvas/contexts/bitmapcontext.py b/rendercanvas/contexts/bitmapcontext.py index 879d1d76..21221126 100644 --- a/rendercanvas/contexts/bitmapcontext.py +++ b/rendercanvas/contexts/bitmapcontext.py @@ -81,7 +81,7 @@ def __init__(self, present_info): assert self._present_info["method"] == "bitmap" self._bitmap_and_format = None - def _rc_present(self): + def _rc_present(self, *, force_sync: bool = False): if self._bitmap_and_format is None: return {"method": "skip"} @@ -130,7 +130,7 @@ def __init__(self, present_info): self._create_wgpu_py_context() # sets self._wgpu_context self._wgpu_context_is_configured = False - def _rc_present(self): + def _rc_present(self, *, force_sync: bool = False): if self._bitmap_and_format is None: return {"method": "skip"} diff --git a/rendercanvas/contexts/wgpucontext.py b/rendercanvas/contexts/wgpucontext.py index d818376b..d2cb7334 100644 --- a/rendercanvas/contexts/wgpucontext.py +++ b/rendercanvas/contexts/wgpucontext.py @@ -2,6 +2,7 @@ from typing import Sequence from .basecontext import BaseContext +from .._coreutils import log_exception __all__ = ["WgpuContext", "WgpuContextToBitmap", "WgpuContextToScreen"] @@ -126,7 +127,7 @@ def get_current_texture(self) -> object: def _get_current_texture(self): raise NotImplementedError() - def _rc_present(self) -> None: + def _rc_present(self, *, force_sync: bool = False) -> None: """Hook for the canvas to present the rendered result. Present what has been drawn to the current texture, by compositing it to the @@ -163,7 +164,7 @@ def _unconfigure(self) -> None: def _get_current_texture(self) -> object: return self._wgpu_context.get_current_texture() - def _rc_present(self) -> None: + def _rc_present(self, *, force_sync: bool = False) -> None: self._wgpu_context.present() return {"method": "screen"} @@ -315,32 +316,18 @@ def _get_current_texture(self): return self._texture - def _rc_present(self) -> None: + def _rc_present(self, *, force_sync: bool = False) -> None: if not self._texture: return {"method": "skip"} - # TODO: in some cases, like offscreen backend, we don't want to skip the first frame! - - # # Get bitmap from oldest downloader - # bitmap = None - # downloader = self._downloaders.pop(0) - # try: - # bitmap = downloader.get_bitmap() - # finally: - # self._downloaders.append(downloader) - - def resolver(buf): - bitmap = downloader.get_bitmap() # todo: read from mapped buffer instead? or have an awaitable that returns memory - if bitmap is None: - return {"method": "skip"} - else: - return {"method": "bitmap", "format": "rgba-u8", "data": bitmap} - # Select new downloader downloader = self._downloaders[-1] - awaitable = downloader.initiate_download(self._texture).then(resolver) - return {"method": "async", "awaitable": awaitable} + if force_sync: + return downloader.do_sync_download(self._texture) + else: + awaitable = downloader.initiate_download(self._texture) + return {"method": "async", "awaitable": awaitable} # downloader._awaitable @@ -367,20 +354,158 @@ def __init__(self, device, buffer_usage): self._device = device self._buffer_usage = buffer_usage self._buffer = None - self._time = 0 + self._awaitable = None + self._action = None + self._time_since_size_ok = 0 def initiate_download(self, texture): - # TODO: assert not waiting + # Check state + if self._action is not None and self._action.is_pending(): + map_state = "none" + if self._buffer is not None: + map_state = self._buffer.map_state + raise RuntimeError( + f"Attempt to initiate texture download when previous frame is not yet consumed (buffer map_state {map_state!r})." + ) - self._parse_texture_metadata(texture) - nbytes = self._padded_stride * self._texture_size[1] - self._ensure_size(nbytes) - self._copy_texture(texture) + # Create new action object and make sure that the buffer is the correct size + action = AsyncImageDownloadAction(texture) + stride, nbytes = action.stride, action.nbytes + self._ensure_buffer_size(nbytes) + action.set_buffer(self._buffer) + + # Initiate copying the texture to the buffer + self._queue_command_to_copy_texture(texture, stride) # 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) - return self._awaitable + awaitable = self._buffer.map_async("READ_NOSYNC", 0, nbytes) + + # When the buffer maps, we continue the download/present action + awaitable.then(action.resolve) + + self._action = action + self._awaitable = awaitable + return action + + def do_sync_download(self, texture): + # First clear any pending downloads + if self._action is not None: + self._action.cancel() + if self._awaitable is not None: + if self._buffer.map_state == "pending": + self._awaitable.sync_wait() + if self._buffer.map_state == "mapped": + self._buffer.unmap() + + # Start a fresh download + self.initiate_download(texture) + + # With a fresh action + self._action.cancel() + action = AsyncImageDownloadAction(texture) + action.set_buffer(self._buffer) + + # Async-wait, then resolve + self._awaitable.sync_wait() + result = action.resolve() + + assert result is not None + + if "data" in result: + data = result["data"] + result["data"] = memoryview(bytearray(data)).cast(data.format, data.shape) + + return result + + def _ensure_buffer_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 required_size > buffer.size: + buffer = None # Buffer too small + elif required_size < 0.50 * buffer.size: + buffer = None # Buffer more than twice as large as needed + elif required_size > 0.85 * buffer.size: + self._time_since_size_ok = time.perf_counter() # Size is fine + elif time.perf_counter() - self._time_since_size_ok > 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 + self._buffer = self._device.create_buffer( + label="copy-buffer", size=buffer_size, usage=self._buffer_usage + ) + + def _queue_command_to_copy_texture(self, texture, stride): + source = { + "texture": texture, + "mip_level": 0, + "origin": (0, 0, 0), + } + + destination = { + "buffer": self._buffer, + "offset": 0, + "bytes_per_row": stride, + "rows_per_image": texture.size[1], + } + + # Copy data to temp buffer + encoder = self._device.create_command_encoder() + encoder.copy_texture_to_buffer(source, destination, texture.size) + command_buffer = encoder.finish() + self._device.queue.submit([command_buffer]) + + +class AsyncImageDownloadAction: + """Single-use image download helper object that has a 'then' method (i.e. follows the awaitable pattern a bit).""" + + def __init__(self, texture): + self._callbacks = [] + # The image is stored in wgpu buffer, which needs to get mapped before we can read the bitmap + self._buffer = None + # We examine the texture to understand what the bitmap will look like + self._parse_texture_metadata(texture) + + def set_buffer(self, buffer): + self._buffer = buffer + + def is_pending(self): + return self._buffer is not None + + def cancel(self): + self._buffer = None + + def then(self, callback): + self._callbacks.append(callback) + + def resolve(self, _=None): + # Use log_exception because this is used in a GPUPromise.then() + with log_exception("Error in AsyncImageDownloadAction.resolve:"): + buffer = self._buffer + if buffer is None: + return + self._buffer = None + + try: + # Note: The bitmap array is only valid within this try-context; + # once the buffer unmaps, the underlying memory is freed, attempting to access it can result in a segfault. + mapped_bitmap = self._get_bitmap(buffer) + result = { + "method": "bitmap", + "format": "rgba-u8", + "data": mapped_bitmap, + } + for callback in self._callbacks: + callback(result) + self._callbacks = [] + return result + finally: + buffer.unmap() def _parse_texture_metadata(self, texture): size = texture.size @@ -423,71 +548,34 @@ def _parse_texture_metadata(self, texture): self._padded_stride = padded_stride self._texture_size = size - 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 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 - self._buffer = self._device.create_buffer( - label="copy-buffer", size=buffer_size, usage=self._buffer_usage - ) - - def _copy_texture(self, texture): - source = { - "texture": texture, - "mip_level": 0, - "origin": (0, 0, 0), - } - - destination = { - "buffer": self._buffer, - "offset": 0, - "bytes_per_row": self._padded_stride, - "rows_per_image": self._texture_size[1], - } - - # Copy data to temp buffer - encoder = self._device.create_command_encoder() - encoder.copy_texture_to_buffer(source, destination, texture.size) - command_buffer = encoder.finish() - self._device.queue.submit([command_buffer]) - - def get_bitmap(self): - if self._buffer is None: # todo: more explicit state tracking - return None + # For ImageDownloader + self.stride = padded_stride + self.nbytes = padded_stride * size[1] + def _get_bitmap(self, buffer): memoryview_type = self._memoryview_type + nchannels = self._nchannels 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) + size = self._texture_size + nbytes = self.nbytes + plain_shape = (size[1], size[0], nchannels) # Download from mappable buffer # Because we use `copy=False``, we *must* copy the data. - if self._buffer.map_state == "pending": - self._awaitable.sync_wait() - mapped_data = self._buffer.read_mapped(copy=False) + if buffer.map_state == "pending": + raise RuntimeError("Buffer state is 'pending' in get_bitmap()") + mapped_data = buffer.read_mapped(copy=False) + + # TODO: return mapped data + # TODO: can we pass the mapped data downstream without copying it, i.e. before unmapping the buffer? Saves another copy. # Copy the data if padded_stride > plain_stride: # Copy per row data = memoryview(bytearray(nbytes)).cast(mapped_data.format) i_start = 0 - for i in range(self._texture_size[1]): + for i in range(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 @@ -507,13 +595,10 @@ def get_bitmap(self): # data.shape = -1 # data = memoryview(data) - # TODO: can we pass the mapped data downstream without copying it, i.e. before unmapping the buffer? Saves another copy. - - # Since we use read_mapped(copy=False), we must unmap it *after* we've copied the data. - self._buffer.unmap() - # Represent as memory object to avoid numpy dependency # Equivalent: np.frombuffer(data, np.uint8).reshape(plain_shape) data = data.cast(memoryview_type, plain_shape) + # Note, no unmap yet! + return data diff --git a/rendercanvas/glfw.py b/rendercanvas/glfw.py index 1c15aeab..7f3e6956 100644 --- a/rendercanvas/glfw.py +++ b/rendercanvas/glfw.py @@ -214,6 +214,7 @@ def __init__(self, *args, present_method=None, **kwargs): self._is_minimized = False self._is_in_poll_events = False self._cursor_object = None + self._animation_frame_requested = False # Register callbacks. We may get notified too often, but that's # ok, they'll result in a single draw. @@ -321,13 +322,18 @@ def _rc_gui_poll(self): def _rc_get_present_methods(self): return get_glfw_present_methods(self._window) + def _do_animation_frame(self): + self._animation_frame_requested = False + self._on_animation_frame() + def _rc_request_animation_frame(self): - if not self._is_minimized: + if not self._animation_frame_requested and not self._is_minimized: + self._animation_frame_requested = True loop = self._rc_canvas_group.get_loop() - loop.call_soon(self._on_animation_frame) + loop.call_soon(self._do_animation_frame) def _rc_force_draw(self): - self._on_animation_frame() + self._do_animation_frame() def _rc_present_bitmap(self, **kwargs): raise NotImplementedError() @@ -402,7 +408,7 @@ def _on_size_change(self, *args): # updated in the draw callback are alive). if self._is_in_poll_events and not self._is_minimized: # todo: need also force draw? - self._on_animation_frame() + self._do_animation_frame() def _on_mouse_button(self, window, but, action, mods): # Map button being changed, which we use to update self._pointer_buttons. diff --git a/rendercanvas/offscreen.py b/rendercanvas/offscreen.py index 5b59af00..3434eedc 100644 --- a/rendercanvas/offscreen.py +++ b/rendercanvas/offscreen.py @@ -65,7 +65,8 @@ def _rc_request_animation_frame(self): pass def _rc_force_draw(self): - self._on_animation_frame() + pass + # self._on_animation_frame() def _rc_present_bitmap(self, *, data, format, **kwargs): self._last_image = data @@ -119,7 +120,7 @@ def draw(self): This object can be converted to a numpy array (without copying data) using ``np.asarray(arr)``. """ - self._on_animation_frame() + self.force_draw() return self._last_image diff --git a/rendercanvas/qt.py b/rendercanvas/qt.py index bda9b7d6..f81bef62 100644 --- a/rendercanvas/qt.py +++ b/rendercanvas/qt.py @@ -373,10 +373,10 @@ def paintEvent(self, event): # noqa: N802 - this is a Qt method painter.drawImage(rect2, image, rect1) painter.end() - def update(self): - # Bypass Qt's mechanics and request a draw so that the scheduling mechanics work as intended. - # Eventually this will call _request_draw(). - self.request_draw() + # def update(self): + # # Bypass Qt's mechanics and request a draw so that the scheduling mechanics work as intended. + # # Eventually this will call _request_draw(). + # self.request_draw() # %% Methods to implement RenderCanvas @@ -420,7 +420,7 @@ def _rc_request_animation_frame(self): def _rc_force_draw(self): # Call the paintEvent right now. - # This works on all platforms I tested, except on MacOS when drawing with the 'image' method. + # This works on all platforms I tested, except on MacOS when drawing with the 'bitmap' method. # Not sure why this is. It be made to work by calling processEvents() but that has all sorts # of nasty side-effects (e.g. the scheduler timer keeps ticking, invoking other draws, etc.). self.repaint() @@ -462,6 +462,7 @@ def _rc_present_bitmap(self, *, data, format, **kwargs): QtGui.QImage(data, width, height, bytes_per_line, qtformat), data, ) + self.update() # schedule a repaint, so the QImage is rendered def _rc_set_logical_size(self, width, height): width, height = int(width), int(height) From 51550abab1addb3af4e89d29fc1c40554192e80d Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 23 Jan 2026 11:36:13 +0100 Subject: [PATCH 31/50] progress --- rendercanvas/_scheduler.py | 34 ++++-- rendercanvas/base.py | 152 +++++++++++++++++---------- rendercanvas/contexts/basecontext.py | 11 -- rendercanvas/contexts/wgpucontext.py | 32 +++--- rendercanvas/glfw.py | 27 +++-- rendercanvas/jupyter.py | 18 ++-- rendercanvas/offscreen.py | 11 +- rendercanvas/pyodide.py | 12 ++- rendercanvas/qt.py | 28 +++-- rendercanvas/stub.py | 12 +-- rendercanvas/wx.py | 13 ++- tests/test_base.py | 20 ++-- tests/test_context.py | 8 +- tests/test_loop.py | 4 +- tests/test_scheduling.py | 9 +- tests/test_sniffio.py | 4 +- 16 files changed, 234 insertions(+), 161 deletions(-) diff --git a/rendercanvas/_scheduler.py b/rendercanvas/_scheduler.py index b40955b5..be24e880 100644 --- a/rendercanvas/_scheduler.py +++ b/rendercanvas/_scheduler.py @@ -51,6 +51,7 @@ def __init__( # ... = canvas.get_context() -> No, context creation should be lazy! # Scheduling variables + self.set_enabled(True) self.set_update_mode(update_mode, min_fps=min_fps, max_fps=max_fps) self._draw_requested = True # Start with a draw in ondemand mode self._ready_for_present = None @@ -92,6 +93,11 @@ def set_update_mode(self, update_mode, *, min_fps=None, max_fps=None): ) self._max_fps = max(1, float(max_fps)) + def set_enabled(self, enabled: bool): + self._enabled = bool(enabled) + if self._enabled: + self._draw_requested = True + def request_draw(self): """Request a new draw to be done. Only affects the 'ondemand' mode.""" # Just set the flag @@ -108,7 +114,9 @@ async def __scheduler_task(self): while True: # Determine delay - if self._mode == "fastest" or self._max_fps <= 0: + if not self._enabled: + delay = 0.1 + elif self._mode == "fastest" or self._max_fps <= 0: delay = 0 else: delay = 1 / self._max_fps @@ -129,7 +137,9 @@ async def __scheduler_task(self): do_draw = False - if self._mode == "fastest": + if not self._enabled: + pass + elif self._mode == "fastest": # fastest: draw continuously as fast as possible, ignoring fps settings. do_draw = True elif self._mode == "continuous": @@ -192,21 +202,27 @@ async def __scheduler_task(self): # so why bother. self._ready_for_present = Event() - canvas._initiate_draw() - canvas._initiate_present() # todo: this was split in two to allow waiting in between. Do we want to keep that option, or simplify the code? - await self._ready_for_present.wait() - - del canvas + canvas._rc_request_draw() + del canvas + if self._ready_for_present: + await self._ready_for_present.wait() + else: + del canvas # Note that when the canvas is closed, we may detect it here and break from the loop. # But the task may also be waiting for a draw to happen, or something else. In that case # this task will be cancelled when the loop ends. In any case, this is why this is not # a good place to detect the canvas getting closed, the loop does this. - def on_draw(self): - # Bookkeeping + def on_cancel_draw(self): + if self._ready_for_present is not None: + self._ready_for_present.set() + self._ready_for_present = None + + def on_about_to_draw(self): self._draw_requested = False + def on_draw_done(self): # Keep ticking if self._ready_for_present is not None: self._ready_for_present.set() diff --git a/rendercanvas/base.py b/rendercanvas/base.py index 18b0afb8..91827994 100644 --- a/rendercanvas/base.py +++ b/rendercanvas/base.py @@ -161,11 +161,11 @@ def __init__( err.add_note(msg) raise - # If this is a wrapper, no need to initialize furher + # If this is a wrapper, no need to initialize further if isinstance(self, WrapperRenderCanvas): return - # The vsync is not-so-elegantly strored on the canvas, and picked up by wgou's canvas contex. + # The vsync is not-so-elegantly stored on the canvas, and picked up by wgou's canvas contex. self._vsync = bool(vsync) # Handle custom present method @@ -174,6 +174,7 @@ def __init__( f"The canvas present_method should be None or str, not {present_method!r}." ) self._present_method = present_method + self._present_to_screen: bool | None = None # set in .get_context() # Variables and flags used internally self.__is_drawing = False @@ -342,7 +343,7 @@ def get_context(self, context_type: str | type) -> contexts.BaseContext: raise RuntimeError( f"Present info method field ({info.get('method')!r}) is not part of the available methods {set(present_methods)}." ) - self._present_method = info["method"] + self._present_to_screen = info["method"] == "screen" # Add some info present_info = {**info, "source": self.__class__.__name__, "vsync": self._vsync} @@ -494,64 +495,62 @@ def force_draw(self) -> None: """ if self.__is_drawing: raise RuntimeError("Cannot force a draw while drawing.") - if self._canvas_context.draw_must_be_in_animation_frame: - self._rc_force_draw() + if self._present_to_screen: + self._rc_force_paint() else: self._draw() self._present(force_sync=True) - self._rc_force_draw() # May or may not work + self._rc_force_paint() # May or may not work - def _initiate_draw(self): - """Initiate drawing the next frame. Called from the scheduler. + def _time_to_draw(self): + """It's time to draw! - This may draw right now, or schedule the drawing, depending on the context's requirements. - Together with `_initiate_present` the methods `_draw`, `_present` and `_finish_present` should all be called. + To get here, ``_rc_request_draw()`` was probably called first: - The screen path: + _rc_request_draw() -> ... -> _time_to_draw() - _initiate_draw() -> _rc_request_animation_frame() -> ... -> _on_animation_frame() -> _draw() _present() _finish_present() - _initiate_present() -> noop + The drawing happens asynchronously, and follows a different path for the different present methods. - The bitmap path: + For the 'screen' method: - _initiate_draw() -> _draw() - _initiate_present() -> _present() -> ... -> finish_present() -> _rc_request_animation_frame() + _rc_request_paint() -> ... -> _time_to_paint() -> _draw() _present() _finish_present() + + For the 'bitmap' method: + + _draw() _present() -> ... -> finish_present() -> _rc_request_paint() """ - if self._canvas_context is None: - pass - elif self._canvas_context.draw_must_be_in_animation_frame: - self._rc_request_animation_frame() - else: - self._draw() + # If we get here, this was most likely because the scheduler called _rc_request_draw(). + # The responsibility is to follow one of the paths above, and make sure that we call the scheduler's on_cancel_draw(), or on_about_to_draw() and on_draw_done(). - def _initiate_present(self): - """Initiate presenting the most recent drawn frame. Called from the scheduler. + # TODO: process events here! - See `_initiate_draw` for details. - """ - if self._canvas_context is None: - pass - elif self._canvas_context.draw_must_be_in_animation_frame: - pass # done from _on_animation_frame + if self._present_to_screen is None: + if self.__scheduler is not None: + self.__scheduler.on_cancel_draw() + elif self._present_to_screen: + self._rc_request_paint() else: + self._draw() self._present() def _draw_frame_and_present(self): # Deprecated - raise RuntimeError("_draw_frame_and_present is renamed to _on_animation_frame") + raise RuntimeError( + "_draw_frame_and_present is removed in favor of _time_to_draw and _time_to_paint" + ) - def _on_animation_frame(self): - """Called by the backend in an animation frame. + def _time_to_paint(self): + """Callback for _rc_request_paint. + This should be called inside the backend's native 'paint event' a.k.a. 'animation frame'. From a scheduling perspective, when this is called, a frame is 'consumed' by the backend. Errors are logged to the "rendercanvas" logger. """ - if ( - not self._canvas_context.draw_must_be_in_animation_frame - ): # TODO: mmm maybe also with BitmapContextToScreen? + # For bitmap present, this is a no-op + if not self._present_to_screen: return # Re-entrant drawing is problematic. Let's actively prevent it. @@ -560,19 +559,23 @@ def _on_animation_frame(self): self.__is_drawing = True try: - if self._canvas_context: # todo: and else? - # todo: can/should we detect whether a draw was already done, so we also draw if somehow this is called without draw being called first? - self._draw() - self._present() + # todo: can/should we detect whether a draw was already done, so we also draw if somehow this is called without draw being called first? + self._draw() + self._present() finally: self.__is_drawing = False def _draw(self): """Draw the frame.""" + if self.__scheduler is not None: + self.__scheduler.on_about_to_draw() + # Cannot draw to a closed canvas. # todo: these checks ... need also in present-xxx I suppose? if self._rc_get_closed() or self._draw_frame is None: + if self.__scheduler is not None: + self.__scheduler.on_cancel_draw() return # Note: could check whether the known physical size is > 0. @@ -596,19 +599,25 @@ def _present(self, *, force_sync=False): # Start the presentation process. context = self._canvas_context - if context: + if context is None: + if self.__scheduler is not None: + self.__scheduler.on_cancel_draw() + else: with log_exception("Present init error"): # Note: we use canvas._canvas_context, so that if the draw_frame is a stub we also dont trigger creating a context. # Note: if vsync is used, this call may wait a little (happens down at the level of the driver or OS) result = context._rc_present(force_sync=force_sync) if result["method"] == "async": + if force_sync: + breakpoint() + assert not force_sync result["awaitable"].then(self._finish_present) else: self._finish_present(result) # result = context._rc_present(callback) - # if context.draw_must_be_in_animation_frame: + # if self._present_to_screen: # assert result is not None # finalize_present(result) @@ -630,7 +639,7 @@ def _finish_present(self, result): # Notify the scheduler if self.__scheduler is not None: - frame_time = self.__scheduler.on_draw() + frame_time = self.__scheduler.on_draw_done() # Maybe update title if frame_time is not None: @@ -640,6 +649,16 @@ def _finish_present(self, result): if "$fps" in raw_title or "$ms" in raw_title: self.set_title(self.__title_info["raw"]) + def _set_visible(self, visible: bool): + """Set whether the canvas is visible or not. + + This is meant for the backend to automatically enable/disable + the rendering when the canvas is e.g. minimized or otherwise invisible. + If not visible, frames are not rendered, but events are still processed. + """ + if self.__scheduler is not None: + self.__scheduler.set_enabled(visible) + # %% Primary canvas management methods def get_logical_size(self) -> tuple[float, float]: @@ -774,26 +793,47 @@ def _rc_get_present_info(self, present_methods: list[str]) -> dict | None: """ return None - def _rc_request_animation_frame(self): - """Request the GUI layer to perform a draw. + def _rc_request_draw(self): + """Request the backend to call ``_time_to_draw()``. + + The backend must call ``_time_to_draw`` as soon as it's ready for the + next frame. It is allowed to call it directly (rather than scheduling + it). It can also be called later, but it must be called eventually, + otherwise it will halt the rendering. + + This functionality allows the backend to throttle the frame rate. For + instance, backends that implement 'remote' rendering can allow new + frames based on the number of in-flight frames and downstream + throughput. + """ + self._time_to_draw() # Simple default + + def _rc_request_paint(self): + """Request the backend to do a paint, and call ``_time_to_paint()``. + + The backend must schedule ``_time_to_paint`` to be called as soon as + possible, but (if applicable) it must be in the native animation frame, + a.k.a. draw event. This function is analog to ``requestAnimationFrame`` + in JavaScript. In any case, inside ``_time_to_paint()`` a call like + ``context.get_current_texture()`` should be allowed. - Like requestAnimationFrame in JS. The draw must be performed - by calling ``_on_animation_frame()``. It's the responsibility - for the canvas subclass to make sure that a draw is made as - soon as possible. + When the present-method is 'screen', this method is called to initiate a + draw. When the present-method is 'bitmap', it is called when the draw + (and present) is completed, so the native system can repaint with the + latest rendered frame. - The default implementation does nothing, which is equivalent to waiting - for a forced draw or a draw invoked by the GUI system. + If the implementation of this method does nothing, it is equivalent to + waiting for a forced draw or a draw invoked by the GUI system. """ pass - def _rc_force_draw(self): - """Perform a synchronous draw. + def _rc_force_paint(self): + """Perform a synchronous paint. - When it returns, the draw must have been done. - The default implementation just calls ``_on_animation_frame()``. + The backend should, if possible, invoke its native paint event right now (synchronously). + The default implementation just calls ``_time_to_paint()``. """ - self._on_animation_frame() + self._time_to_paint() def _rc_present_bitmap(self, *, data, format, **kwargs): """Present the given image bitmap. Only used with present_method 'bitmap'. diff --git a/rendercanvas/contexts/basecontext.py b/rendercanvas/contexts/basecontext.py index d300fb57..88d811d5 100644 --- a/rendercanvas/contexts/basecontext.py +++ b/rendercanvas/contexts/basecontext.py @@ -9,17 +9,6 @@ class BaseContext: # Subclasses must define their present-methods that they support, in oder of preference present_methods = [] - # Whether drawing must occur in the backend's native animation frame. - # Applies to WgpuContextToScreen, i.e. rendering to a Qt widget or a - # browser's . The main reason is the context.get_current_texture() - # call that's done during the draw, which these systems need to align with - # the native drawing cycle. If this is False, the draw step can be separated - # from the present, which is important for the WgpuContextToBitmap to - # async-download the bitmap. - draw_must_be_in_animation_frame = ( - False # TODO: actually this is just present_method == 'screen'! - ) - def __init__(self, present_info: dict): self._present_info = present_info assert present_info["method"] in ("bitmap", "screen") # internal sanity check diff --git a/rendercanvas/contexts/wgpucontext.py b/rendercanvas/contexts/wgpucontext.py index d2cb7334..550d7fe5 100644 --- a/rendercanvas/contexts/wgpucontext.py +++ b/rendercanvas/contexts/wgpucontext.py @@ -145,8 +145,6 @@ class WgpuContextToScreen(WgpuContext): present_methods = ["screen"] - draw_must_be_in_animation_frame = True - def __init__(self, present_info: dict): super().__init__(present_info) assert self._present_info["method"] == "screen" @@ -300,16 +298,16 @@ def _get_current_texture(self): # * raise an error # 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: - width, height = self.physical_size - width, height = max(width, 1), max(height, 1) + width, height = self.physical_size + need_texture_size = max(width, 1), max(height, 1), 1 + if self._texture is None or self._texture.size != need_texture_size: # Note that the label 'present' is used by read_texture() to determine # that it can use a shared copy buffer. device = self._config["device"] self._texture = device.create_texture( label="present", - size=(width, height, 1), + size=need_texture_size, format=self._config["format"], usage=self._config["usage"] | self._context_texture_usage, ) @@ -358,7 +356,7 @@ def __init__(self, device, buffer_usage): self._action = None self._time_since_size_ok = 0 - def initiate_download(self, texture): + def _get_awaitable_for_download(self, texture): # Check state if self._action is not None and self._action.is_pending(): map_state = "none" @@ -381,12 +379,16 @@ def initiate_download(self, texture): # 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 = self._buffer.map_async("READ_NOSYNC", 0, nbytes) - # When the buffer maps, we continue the download/present action - awaitable.then(action.resolve) - self._action = action self._awaitable = awaitable - return action + + def initiate_download(self, texture): + self._get_awaitable_for_download(texture) + + # When the buffer maps, we continue the download/present action + self._awaitable.then(self._action.resolve) + + return self._action def do_sync_download(self, texture): # First clear any pending downloads @@ -399,7 +401,7 @@ def do_sync_download(self, texture): self._buffer.unmap() # Start a fresh download - self.initiate_download(texture) + self._get_awaitable_for_download(texture) # With a fresh action self._action.cancel() @@ -410,6 +412,8 @@ def do_sync_download(self, texture): self._awaitable.sync_wait() result = action.resolve() + if result is None: + breakpoint() assert result is not None if "data" in result: @@ -558,7 +562,6 @@ def _get_bitmap(self, buffer): plain_stride = self._plain_stride padded_stride = self._padded_stride size = self._texture_size - nbytes = self.nbytes plain_shape = (size[1], size[0], nchannels) # Download from mappable buffer @@ -573,7 +576,8 @@ def _get_bitmap(self, buffer): # Copy the data if padded_stride > plain_stride: # Copy per row - data = memoryview(bytearray(nbytes)).cast(mapped_data.format) + nbytes_clean = plain_shape[0] * plain_shape[1] * plain_shape[2] + data = memoryview(bytearray(nbytes_clean)).cast(mapped_data.format) i_start = 0 for i in range(size[1]): row = mapped_data[i * padded_stride : i * padded_stride + plain_stride] diff --git a/rendercanvas/glfw.py b/rendercanvas/glfw.py index 4e042f49..24d0ff9a 100644 --- a/rendercanvas/glfw.py +++ b/rendercanvas/glfw.py @@ -209,7 +209,6 @@ def __init__(self, *args, **kwargs): self._is_minimized = False self._is_in_poll_events = False self._cursor_object = None - self._animation_frame_requested = False # Register callbacks. We may get notified too often, but that's # ok, they'll result in a single draw. @@ -253,8 +252,7 @@ def _on_window_dirty(self, *args): def _on_iconify(self, window, iconified): self._is_minimized = bool(iconified) - if not self._is_minimized: - self._rc_request_animation_frame() + self._set_visible(not iconified) def _determine_size(self): if self._window is None: @@ -320,18 +318,18 @@ def _rc_get_present_info(self, present_methods): else: return None # raises error - def _do_animation_frame(self): - self._animation_frame_requested = False - self._on_animation_frame() + def _rc_request_draw(self): + self._time_to_draw() - def _rc_request_animation_frame(self): - if not self._animation_frame_requested and not self._is_minimized: - self._animation_frame_requested = True - loop = self._rc_canvas_group.get_loop() - loop.call_soon(self._do_animation_frame) + def _rc_request_paint(self): + loop = self._rc_canvas_group.get_loop() + loop.call_soon(self._paint) - def _rc_force_draw(self): - self._do_animation_frame() + def _rc_force_paint(self): + self._paint() + + def _paint(self): + self._time_to_paint() def _rc_present_bitmap(self, **kwargs): raise NotImplementedError() @@ -405,8 +403,7 @@ def _on_size_change(self, *args): # that rely on the event-loop are paused (only animations # updated in the draw callback are alive). if self._is_in_poll_events and not self._is_minimized: - # todo: need also force draw? - self._do_animation_frame() + self._time_to_paint() def _on_mouse_button(self, window, but, action, mods): # Map button being changed, which we use to update self._pointer_buttons. diff --git a/rendercanvas/jupyter.py b/rendercanvas/jupyter.py index ff9d0ea4..744aacc3 100644 --- a/rendercanvas/jupyter.py +++ b/rendercanvas/jupyter.py @@ -37,11 +37,11 @@ def __init__(self, *args, **kwargs): self._final_canvas_init() def get_frame(self): - # The _on_animation_frame() does the drawing and then calls + # The _time_to_draw() does the drawing and then calls # present_context.present(), which calls our present() method. # The result is either a numpy array or None, and this matches # with what this method is expected to return. - self._on_animation_frame() + self._time_to_draw() return self._last_image # %% Methods to implement RenderCanvas @@ -63,17 +63,23 @@ def _rc_get_present_info(self, present_methods): else: return None # raises error - def _rc_request_animation_frame(self): + def _rc_request_draw(self): self._draw_request_time = time.perf_counter() - RemoteFrameBuffer.request_draw(self) + RemoteFrameBuffer.request_draw(self) # -> get_frame() -> _time_to_draw() - def _rc_force_draw(self): + def _rc_request_paint(self): + # We technically don't need to call _time_to_paint, because this backend only does bitmap mode. + # But in case the base backend will do something in _time_to_paint later, we behave nice. + loop = self._rc_canvas_group.get_loop() + loop.call_soon(self._time_to_paint) + + def _rc_force_paint(self): # A bit hacky to use the internals of jupyter_rfb this way. # This pushes frames to the browser as long as the websocket # buffer permits it. It works! # But a better way would be `await canvas.wait_draw()`. # Todo: would also be nice if jupyter_rfb had a public api for this. - array = self.get_frame() + array = self._last_image if array is not None: self._rfb_send_frame(array) diff --git a/rendercanvas/offscreen.py b/rendercanvas/offscreen.py index 1882ce4f..43245fb0 100644 --- a/rendercanvas/offscreen.py +++ b/rendercanvas/offscreen.py @@ -61,14 +61,17 @@ def _rc_get_present_info(self, present_methods): else: return None # raises error - def _rc_request_animation_frame(self): + def _rc_request_draw(self): + # No need to wait + self._time_to_draw() + + def _rc_request_paint(self): # Ok, cool, the scheduler want a draw. But we only draw when the user # calls draw(), so that's how this canvas ticks. pass - def _rc_force_draw(self): - pass - # self._on_animation_frame() + def _rc_force_paint(self): + self._time_to_paint() def _rc_present_bitmap(self, *, data, format, **kwargs): self._last_image = data diff --git a/rendercanvas/pyodide.py b/rendercanvas/pyodide.py index fa7657f6..4b33c168 100644 --- a/rendercanvas/pyodide.py +++ b/rendercanvas/pyodide.py @@ -444,14 +444,18 @@ def _rc_get_present_info(self, present_methods): else: return None # raises error - def _rc_request_animation_frame(self): - window.requestAnimationFrame(create_proxy(lambda _: self._on_animation_frame())) + def _rc_request_draw(self): + # No need to wait + self._time_to_draw() - def _rc_force_draw(self): + def _rc_request_paint(self): + window.requestAnimationFrame(create_proxy(lambda _: self._time_to_paint())) + + def _rc_force_paint(self): # Not very clean to do this, and not sure if it works in a browser; # you can draw all you want, but the browser compositer only uses the last frame, I expect. # But that's ok, since force-drawing is not recommended in general. - self._on_animation_frame() + self._time_to_paint() def _rc_present_bitmap(self, **kwargs): data = kwargs.get("data") diff --git a/rendercanvas/qt.py b/rendercanvas/qt.py index 1cf6f1bd..3ed71290 100644 --- a/rendercanvas/qt.py +++ b/rendercanvas/qt.py @@ -36,6 +36,7 @@ WA_PaintOnScreen = QtCore.Qt.WidgetAttribute.WA_PaintOnScreen WA_DeleteOnClose = QtCore.Qt.WidgetAttribute.WA_DeleteOnClose PreciseTimer = QtCore.Qt.TimerType.PreciseTimer + WindowState = QtCore.Qt.WindowState FocusPolicy = QtCore.Qt.FocusPolicy CursorShape = QtCore.Qt.CursorShape WinIdChange = QtCore.QEvent.Type.WinIdChange @@ -50,6 +51,7 @@ WA_PaintOnScreen = QtCore.Qt.WA_PaintOnScreen WA_DeleteOnClose = QtCore.Qt.WA_DeleteOnClose PreciseTimer = QtCore.Qt.PreciseTimer + WindowState = QtCore.Qt FocusPolicy = QtCore.Qt CursorShape = QtCore.Qt WinIdChange = QtCore.QEvent.WinIdChange @@ -281,7 +283,6 @@ def __init__(self, *args, **kwargs): # Determine present method self._last_image = None self._last_winid = None - self._present_to_screen = None self._is_closed = False self.setAutoFillBackground(False) @@ -335,7 +336,7 @@ def paintEngine(self): # noqa: N802 - this is a Qt method return super().paintEngine() def paintEvent(self, event): # noqa: N802 - this is a Qt method - self._on_animation_frame() + self._time_to_paint() if self._last_image is not None: image = self._last_image[0] @@ -390,10 +391,8 @@ def _rc_get_present_info(self, present_methods): if the_method == "screen": surface_ids = self._get_surface_ids() if surface_ids: - self._present_to_screen = True return {"method": "screen", **surface_ids} elif "bitmap" in present_methods: - self._present_to_screen = False return { "method": "bitmap", "formats": list(BITMAP_FORMAT_MAP.keys()), @@ -401,7 +400,6 @@ def _rc_get_present_info(self, present_methods): else: return None elif the_method == "bitmap": - self._present_to_screen = False return { "method": "bitmap", "formats": list(BITMAP_FORMAT_MAP.keys()), @@ -409,7 +407,10 @@ def _rc_get_present_info(self, present_methods): else: return None # raises error - def _rc_request_animation_frame(self): + def _rc_request_draw(self): + self._time_to_draw() + + def _rc_request_paint(self): # Ask Qt to do a paint event QtWidgets.QWidget.update(self) @@ -418,7 +419,7 @@ def _rc_request_animation_frame(self): if not isinstance(loop, QtLoop): loop.call_soon(self._rc_gui_poll) - def _rc_force_draw(self): + def _rc_force_paint(self): # Call the paintEvent right now. # This works on all platforms I tested, except on MacOS when drawing with the 'bitmap' method. # Not sure why this is. It be made to work by calling processEvents() but that has all sorts @@ -681,6 +682,19 @@ def update(self): def closeEvent(self, event): # noqa: N802 self._subwidget.closeEvent(event) + def changeEvent(self, event): # noqa: N802 + # Pause rendering when minimized. Note that it is about impossible to + # detect this from the widget itself, let alone other ways in which the + # widget becomes invisible, such as in a non-active tab, behind the + # window of another application, etc. + # So we keep this implementation minimal, and leave it to the end-user if more sophisticated methods are needed. + # Note that for present-method 'screen', this is not really needed, because Qt does not paint (i.e. animation frame) + # when hidden. So this is mainly for when 'bitmap' mode is used. + if event.type().name == "WindowStateChange": + minimized = self.windowState() & WindowState.WindowMinimized + self._subwidget._set_visible(not minimized) + return super().changeEvent(event) + # Make available under a name that is the same for all gui backends RenderWidget = QRenderWidget diff --git a/rendercanvas/stub.py b/rendercanvas/stub.py index 9ededc47..31d79782 100644 --- a/rendercanvas/stub.py +++ b/rendercanvas/stub.py @@ -68,9 +68,6 @@ class StubRenderCanvas(BaseRenderCanvas): Backends must call ``self._final_canvas_init()`` at the end of its ``__init__()``. This will set the canvas' logical size and title. - Backends must call ``self._on_animation_frame()`` to make the actual - draw. This should typically be done inside the backend's native draw event. - Backends must call ``self._size_info.set_physical_size(width, height, native_pixel_ratio)``, whenever the size or pixel ratio changes. It must be called when the actual viewport has changed, so typically not in ``_rc_set_logical_size()``, but @@ -91,11 +88,14 @@ def _rc_gui_poll(self): def _rc_get_present_info(self, present_methods): return None - def _rc_request_animation_frame(self): + def _rc_request_draw(self): + self._time_to_draw() + + def _rc_request_paint(self): pass - def _rc_force_draw(self): - self._on_animation_frame() + def _rc_force_paint(self): + self._time_to_paint() def _rc_present_bitmap(self, *, data, format, **kwargs): raise NotImplementedError() diff --git a/rendercanvas/wx.py b/rendercanvas/wx.py index 83c309fc..e5bb12f2 100644 --- a/rendercanvas/wx.py +++ b/rendercanvas/wx.py @@ -211,7 +211,6 @@ class WxRenderWidget(BaseRenderCanvas, wx.Window): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self._present_to_screen = None self._is_closed = False self._pointer_inside = None self._is_pointer_inside_according_to_wx = False @@ -246,7 +245,7 @@ def _on_resize_done(self, *args): def on_paint(self, event): dc = wx.PaintDC(self) # needed for wx if not self._draw_lock: - self._on_animation_frame() + self._time_to_paint() del dc event.Skip() @@ -305,10 +304,8 @@ def _rc_get_present_info(self, present_methods): loop.process_wx_events() surface_ids = self._get_surface_ids() if surface_ids: - self._present_to_screen = True return {"method": "screen", **surface_ids} elif "bitmap" in present_methods: - self._present_to_screen = False return { "method": "bitmap", "formats": ["rgba-u8"], @@ -316,7 +313,6 @@ def _rc_get_present_info(self, present_methods): else: return None elif the_method == "bitmap": - self._present_to_screen = False return { "method": "bitmap", "formats": ["rgba-u8"], @@ -324,7 +320,10 @@ def _rc_get_present_info(self, present_methods): else: return None # raises error - def _rc_request_animation_frame(self): + def _rc_request_draw(self): + self._time_to_draw() + + def _rc_request_paint(self): if self._draw_lock: return try: @@ -332,7 +331,7 @@ def _rc_request_animation_frame(self): except Exception: pass # avoid errors when window no longer lives - def _rc_force_draw(self): + def _rc_force_paint(self): self.Refresh() self.Update() diff --git a/tests/test_base.py b/tests/test_base.py index b34155bb..29ac8bf9 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -43,10 +43,10 @@ def test_canvas_logging(caplog): canvas = CanvasThatRaisesErrorsDuringDrawing() - canvas._on_animation_frame() # prints traceback - canvas._on_animation_frame() # prints short logs ... - canvas._on_animation_frame() - canvas._on_animation_frame() + canvas.force_draw() # prints traceback + canvas.force_draw() # prints short logs ... + canvas.force_draw() + canvas.force_draw() text = caplog.text assert text.count("bar_method") == 2 # one traceback => 2 mentions @@ -58,10 +58,10 @@ def test_canvas_logging(caplog): assert text.count("spam_method") == 0 assert text.count("intended-fail") == 0 - canvas._on_animation_frame() # prints traceback - canvas._on_animation_frame() # prints short logs ... - canvas._on_animation_frame() - canvas._on_animation_frame() + canvas.force_draw() # prints traceback + canvas.force_draw() # prints short logs ... + canvas.force_draw() + canvas.force_draw() text = caplog.text assert text.count("bar_method") == 2 # one traceback => 2 mentions @@ -99,10 +99,10 @@ def test_run_bare_canvas(): # canvas = RenderCanvas() # loop.run() # - # Note: loop.run() calls _on_animation_frame() in event loop. + # Note: loop.run() calls _time_to_draw() in event loop. canvas = MyOffscreenCanvas() - canvas._on_animation_frame() + canvas.force_draw() @mark.skipif(not can_use_wgpu_lib, reason="Needs wgpu lib") diff --git a/tests/test_context.py b/tests/test_context.py index 34d38afe..2afb0b8c 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -56,13 +56,13 @@ def set_physical_size(self, w, h): self._rc_set_size_dict(size_info) def present(self): - return self._rc_present() + return self._rc_present(force_sync=True) def close(self): self._rc_close() -class BitmapContextToWgpuAndBackToBimap(BitmapContextToScreen): +class BitmapContextToWgpuAndBackToBitmap(BitmapContextToScreen): """A bitmap context that takes a detour via wgpu :)""" present_methods = ["bitmap"] @@ -251,8 +251,8 @@ def test_bitmap_context(): def test_wgpu_context(): # Create canvas and attach our special adapter canvas canvas = ManualOffscreenRenderCanvas() - context = canvas.get_context(BitmapContextToWgpuAndBackToBimap) - assert isinstance(context, BitmapContextToWgpuAndBackToBimap) + context = canvas.get_context(BitmapContextToWgpuAndBackToBitmap) + assert isinstance(context, BitmapContextToWgpuAndBackToBitmap) assert isinstance(context, BitmapContext) # Create and set bitmap diff --git a/tests/test_loop.py b/tests/test_loop.py index 94dbb0a8..de6156a8 100644 --- a/tests/test_loop.py +++ b/tests/test_loop.py @@ -159,9 +159,9 @@ def _rc_close(self): def _rc_get_closed(self): return self._is_closed - def _rc_request_animation_frame(self): + def _rc_request_paint(self): loop = self._rc_canvas_group.get_loop() - loop.call_soon(self._on_animation_frame) + loop.call_soon(self._time_to_paint) # %%%%% deleting loops diff --git a/tests/test_scheduling.py b/tests/test_scheduling.py index bd1c240f..06af61b4 100644 --- a/tests/test_scheduling.py +++ b/tests/test_scheduling.py @@ -31,6 +31,7 @@ def __init__(self, *args, **kwargs): self.draw_count = 0 self.events_count = 0 self._gui_draw_requested = False + self._present_to_screen = True # makes this test easier def _rc_close(self): self._closed = True @@ -42,17 +43,17 @@ def _process_events(self): self.events_count += 1 return super()._process_events() - def _on_animation_frame(self): - super()._on_animation_frame() + def _time_to_paint(self): + super()._time_to_paint() self.draw_count += 1 - def _rc_request_animation_frame(self): + def _rc_request_paint(self): self._gui_draw_requested = True def draw_if_necessary(self): if self._gui_draw_requested: self._gui_draw_requested = False - self._on_animation_frame() + self._time_to_paint() def active_sleep(self, delay): loop = self._rc_canvas_group.get_loop() # <---- diff --git a/tests/test_sniffio.py b/tests/test_sniffio.py index de2c1399..4b0a4f45 100644 --- a/tests/test_sniffio.py +++ b/tests/test_sniffio.py @@ -35,9 +35,9 @@ def _rc_close(self): def _rc_get_closed(self): return self._is_closed - def _rc_request_animation_frame(self): + def _rc_request_paint(self): loop = self._rc_canvas_group.get_loop() - loop.call_soon(self._on_animation_frame) + loop.call_soon(self._time_to_paint) def get_sniffio_name(): From cb48dbe38d4eff6ff3abbd68fd162375b4e0434b Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 23 Jan 2026 12:46:19 +0100 Subject: [PATCH 32/50] Some cleanuo --- rendercanvas/base.py | 132 ++++++++++++++++----------------------- tests/test_base.py | 7 +++ tests/test_scheduling.py | 21 +++---- tests/test_sniffio.py | 15 +++-- 4 files changed, 80 insertions(+), 95 deletions(-) diff --git a/rendercanvas/base.py b/rendercanvas/base.py index 91827994..bf55c24d 100644 --- a/rendercanvas/base.py +++ b/rendercanvas/base.py @@ -496,10 +496,9 @@ def force_draw(self) -> None: if self.__is_drawing: raise RuntimeError("Cannot force a draw while drawing.") if self._present_to_screen: - self._rc_force_paint() + self._rc_force_paint() # -> _time_to_paint() -> _draw_and_present() else: - self._draw() - self._present(force_sync=True) + self._draw_and_present(force_sync=True) self._rc_force_paint() # May or may not work def _time_to_draw(self): @@ -513,32 +512,19 @@ def _time_to_draw(self): For the 'screen' method: - _rc_request_paint() -> ... -> _time_to_paint() -> _draw() _present() _finish_present() + _rc_request_paint() -> ... -> _time_to_paint() -> _draw_and_present() -> _finish_present() For the 'bitmap' method: - _draw() _present() -> ... -> finish_present() -> _rc_request_paint() + _draw_and_present() -> ... -> finish_present() -> _rc_request_paint() """ - # If we get here, this was most likely because the scheduler called _rc_request_draw(). - # The responsibility is to follow one of the paths above, and make sure that we call the scheduler's on_cancel_draw(), or on_about_to_draw() and on_draw_done(). - # TODO: process events here! - if self._present_to_screen is None: - if self.__scheduler is not None: - self.__scheduler.on_cancel_draw() - elif self._present_to_screen: - self._rc_request_paint() + if self._present_to_screen: + self._rc_request_paint() # -> _time_to_paint() -> _draw_and_present() else: - self._draw() - self._present() - - def _draw_frame_and_present(self): - # Deprecated - raise RuntimeError( - "_draw_frame_and_present is removed in favor of _time_to_draw and _time_to_paint" - ) + self._draw_and_present() def _time_to_paint(self): """Callback for _rc_request_paint. @@ -548,86 +534,74 @@ def _time_to_paint(self): Errors are logged to the "rendercanvas" logger. """ + if self._present_to_screen: + self._draw_and_present() - # For bitmap present, this is a no-op - if not self._present_to_screen: - return + def _draw_and_present(self, *, force_sync=False): + """Draw the frame and init the presentation.""" # Re-entrant drawing is problematic. Let's actively prevent it. if self.__is_drawing: return self.__is_drawing = True - try: - # todo: can/should we detect whether a draw was already done, so we also draw if somehow this is called without draw being called first? - self._draw() - self._present() - finally: - self.__is_drawing = False - - def _draw(self): - """Draw the frame.""" - - if self.__scheduler is not None: - self.__scheduler.on_about_to_draw() - - # Cannot draw to a closed canvas. - # todo: these checks ... need also in present-xxx I suppose? - if self._rc_get_closed() or self._draw_frame is None: - if self.__scheduler is not None: - self.__scheduler.on_cancel_draw() - return - - # Note: could check whether the known physical size is > 0. - # But we also consider it the responsibility of the backend to not - # draw if the size is zero. GUI toolkits like Qt do this correctly. - # I might get back on this once we also draw outside of the draw-event ... - - # Make sure that the user-code is up-to-date with the current size before it draws. - self.__maybe_emit_resize_event() - - # Emit before-draw - self._events.emit({"event_type": "before_draw"}) + # Note that this method is responsible for notifying the scheduler for the draw getting canceled or done. + # In other words, an early return should always be proceeded with a call to scheduler.on_cancel_draw(), + # otherwise the drawing gets stuck. (The re-entrant logic is the one exception.) - # Perform the user-defined drawing code. When this errors, - # we should report the error and then continue, otherwise we crash. - with log_exception("Draw error"): - self._draw_frame() - - def _present(self, *, force_sync=False): - """Present the frame. Can be direct or async.""" - - # Start the presentation process. - context = self._canvas_context - if context is None: - if self.__scheduler is not None: - self.__scheduler.on_cancel_draw() - else: + try: + scheduler = self.__scheduler # May be None + context = self._canvas_context # May be None + + # Let scheduler know that the draw is about to take place, resets the draw_requested flag. + if scheduler is not None: + scheduler.on_about_to_draw() + + # Check size + w, h = self.get_physical_size() + size_is_nill = not (w > 0 and h > 0) + + # Cancel the draw if the conditions aren't right + if ( + size_is_nill + or context is None + or self._draw_frame is None + or self._rc_get_closed() + ): + if scheduler is not None: + scheduler.on_cancel_draw() + return + + # Make sure that the user-code is up-to-date with the current size before it draws. + self.__maybe_emit_resize_event() + + # Emit before-draw + self._events.emit({"event_type": "before_draw"}) + + # Perform the user-defined drawing code. When this errors, + # we should report the error and then continue, otherwise we crash. + with log_exception("Draw error"): + self._draw_frame() + + # Perform the presentation process. Might be async with log_exception("Present init error"): - # Note: we use canvas._canvas_context, so that if the draw_frame is a stub we also dont trigger creating a context. # Note: if vsync is used, this call may wait a little (happens down at the level of the driver or OS) result = context._rc_present(force_sync=force_sync) if result["method"] == "async": - if force_sync: - breakpoint() - assert not force_sync + assert not force_sync, "forced sync but got async present-result" result["awaitable"].then(self._finish_present) else: self._finish_present(result) - # result = context._rc_present(callback) - # if self._present_to_screen: - # assert result is not None - # finalize_present(result) + finally: + self.__is_drawing = False def _finish_present(self, result): """Wrap up the presentation process.""" - # Callback for the context to finalize the presentation. - # This either gets called from _rc_present, either directly, or via a promise.then() - method = result.pop("method", "unknown") with log_exception("Present finish error"): + method = result.pop("method", "unknown") if method in ("skip", "screen"): pass # nothing we need to do elif method == "fail": diff --git a/tests/test_base.py b/tests/test_base.py index 29ac8bf9..1d98d21f 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -12,10 +12,17 @@ def test_base_canvas_context(): assert hasattr(rendercanvas.BaseRenderCanvas, "get_context") +class StubContext: + def _rc_present(self, *, force_sync=False): + return {"method": "skip"} + + class CanvasThatRaisesErrorsDuringDrawing(rendercanvas.BaseRenderCanvas): def __init__(self): super().__init__() self._count = 0 + self._present_to_screen = False + self._canvas_context = StubContext() def _draw_frame(self): self._count += 1 diff --git a/tests/test_scheduling.py b/tests/test_scheduling.py index 06af61b4..a42b609c 100644 --- a/tests/test_scheduling.py +++ b/tests/test_scheduling.py @@ -22,6 +22,11 @@ class MyLoop(StubLoop): # Note: run() is non-blocking and simply does one iteration to process pending tasks. +class StubContext: + def _rc_present(self, *, force_sync=False): + return {"method": "skip"} + + class MyCanvas(BaseRenderCanvas): _rc_canvas_group = MyCanvasGroup(MyLoop()) @@ -31,7 +36,8 @@ def __init__(self, *args, **kwargs): self.draw_count = 0 self.events_count = 0 self._gui_draw_requested = False - self._present_to_screen = True # makes this test easier + self._present_to_screen = False + self._canvas_context = StubContext() def _rc_close(self): self._closed = True @@ -43,25 +49,16 @@ def _process_events(self): self.events_count += 1 return super()._process_events() - def _time_to_paint(self): - super()._time_to_paint() + def _draw_and_present(self, *, force_sync=False): + super()._draw_and_present(force_sync=force_sync) self.draw_count += 1 - def _rc_request_paint(self): - self._gui_draw_requested = True - - def draw_if_necessary(self): - if self._gui_draw_requested: - self._gui_draw_requested = False - self._time_to_paint() - def active_sleep(self, delay): loop = self._rc_canvas_group.get_loop() # <---- etime = time.perf_counter() + delay while time.perf_counter() < etime: time.sleep(0.001) loop.run() - self.draw_if_necessary() def test_scheduling_manual(): diff --git a/tests/test_sniffio.py b/tests/test_sniffio.py index 4b0a4f45..04cbdbf6 100644 --- a/tests/test_sniffio.py +++ b/tests/test_sniffio.py @@ -24,10 +24,21 @@ class CanvasGroup(BaseCanvasGroup): pass +class StubContext: + def _rc_present(self, *, force_sync=False): + return {"method": "skip"} + + class RealRenderCanvas(BaseRenderCanvas): _rc_canvas_group = CanvasGroup(asyncio_loop) _is_closed = False + def __init__(self): + super().__init__() + self._count = 0 + self._present_to_screen = False + self._canvas_context = StubContext() + def _rc_close(self): self._is_closed = True self.submit_event({"event_type": "close"}) @@ -35,10 +46,6 @@ def _rc_close(self): def _rc_get_closed(self): return self._is_closed - def _rc_request_paint(self): - loop = self._rc_canvas_group.get_loop() - loop.call_soon(self._time_to_paint) - def get_sniffio_name(): try: From a271969f569a92789ada608d466c4ad2b3325ba7 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 23 Jan 2026 12:54:44 +0100 Subject: [PATCH 33/50] fix for pyqt5 --- rendercanvas/qt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rendercanvas/qt.py b/rendercanvas/qt.py index 3ed71290..e62ea202 100644 --- a/rendercanvas/qt.py +++ b/rendercanvas/qt.py @@ -690,7 +690,7 @@ def changeEvent(self, event): # noqa: N802 # So we keep this implementation minimal, and leave it to the end-user if more sophisticated methods are needed. # Note that for present-method 'screen', this is not really needed, because Qt does not paint (i.e. animation frame) # when hidden. So this is mainly for when 'bitmap' mode is used. - if event.type().name == "WindowStateChange": + if event.type() == QtCore.QEvent.WindowStateChange: minimized = self.windowState() & WindowState.WindowMinimized self._subwidget._set_visible(not minimized) return super().changeEvent(event) From d0aefc88b3983e543948f0998b904bc6e39024c1 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 23 Jan 2026 13:13:52 +0100 Subject: [PATCH 34/50] Fix wx bitmap present --- rendercanvas/base.py | 2 ++ rendercanvas/qt.py | 1 - rendercanvas/wx.py | 14 ++++++++------ 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/rendercanvas/base.py b/rendercanvas/base.py index bf55c24d..10dbfa28 100644 --- a/rendercanvas/base.py +++ b/rendercanvas/base.py @@ -610,6 +610,8 @@ def _finish_present(self, result): # Pass the result to the literal present method func = getattr(self, f"_rc_present_{method}") func(**result) + # Now the backend must repaint to show the new image + self._rc_request_paint() # Notify the scheduler if self.__scheduler is not None: diff --git a/rendercanvas/qt.py b/rendercanvas/qt.py index e62ea202..6e81c6c6 100644 --- a/rendercanvas/qt.py +++ b/rendercanvas/qt.py @@ -463,7 +463,6 @@ def _rc_present_bitmap(self, *, data, format, **kwargs): QtGui.QImage(data, width, height, bytes_per_line, qtformat), data, ) - self.update() # schedule a repaint, so the QImage is rendered def _rc_set_logical_size(self, width, height): width, height = int(width), int(height) diff --git a/rendercanvas/wx.py b/rendercanvas/wx.py index e5bb12f2..7bb7f73d 100644 --- a/rendercanvas/wx.py +++ b/rendercanvas/wx.py @@ -211,6 +211,7 @@ class WxRenderWidget(BaseRenderCanvas, wx.Window): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + self._last_image = None self._is_closed = False self._pointer_inside = None self._is_pointer_inside_according_to_wx = False @@ -243,11 +244,14 @@ def _on_resize_done(self, *args): self.Refresh() def on_paint(self, event): - dc = wx.PaintDC(self) # needed for wx + dc = wx.PaintDC(self) if not self._draw_lock: self._time_to_paint() + if self._last_image is not None: + dc.DrawBitmap(self._last_image, 0, 0, False) + else: + event.Skip() del dc - event.Skip() def _get_surface_ids(self): if sys.platform.startswith("win") or sys.platform.startswith("darwin"): @@ -339,10 +343,8 @@ def _rc_present_bitmap(self, *, data, format, **kwargs): # todo: we can easily support more formats here assert format == "rgba-u8" width, height = data.shape[1], data.shape[0] - - dc = wx.PaintDC(self) - bitmap = wx.Bitmap.FromBufferRGBA(width, height, data) - dc.DrawBitmap(bitmap, 0, 0, False) + self._last_image = wx.Bitmap.FromBufferRGBA(width, height, data) + self._last_image.SetScaleFactor(self.get_pixel_ratio()) def _rc_set_logical_size(self, width, height): width, height = int(width), int(height) From 819b8c0cc1ecbf47f6f79d3d660b87d10c1ce9eb Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 23 Jan 2026 16:35:24 +0100 Subject: [PATCH 35/50] fix force-draw for qt and wx on macos --- rendercanvas/qt.py | 11 +++++++---- rendercanvas/wx.py | 2 ++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/rendercanvas/qt.py b/rendercanvas/qt.py index 646ed042..2a01285e 100644 --- a/rendercanvas/qt.py +++ b/rendercanvas/qt.py @@ -411,11 +411,14 @@ def _rc_request_paint(self): loop.call_soon(self._rc_gui_poll) def _rc_force_paint(self): - # Call the paintEvent right now. - # This works on all platforms I tested, except on MacOS when drawing with the 'bitmap' method. - # Not sure why this is. It be made to work by calling processEvents() but that has all sorts - # of nasty side-effects (e.g. the scheduler timer keeps ticking, invoking other draws, etc.). + # Call the paintEvent right now. This works on all platforms I tested, + # except on MacOS when drawing with the 'bitmap' method. Not sure why + # this is. It can be made to work by calling processEvents(), although + # that can have side-effects (e.g. the scheduler timer keeps ticking, + # invoking other draws, etc.). self.repaint() + if sys.platform == "darwin" and not self._present_to_screen: + loop._app.processEvents() def _rc_present_bitmap(self, *, data, format, **kwargs): # Notes on performance: diff --git a/rendercanvas/wx.py b/rendercanvas/wx.py index 38608b47..dc3da45d 100644 --- a/rendercanvas/wx.py +++ b/rendercanvas/wx.py @@ -329,6 +329,8 @@ def _rc_request_paint(self): def _rc_force_paint(self): self.Refresh() self.Update() + if sys.platform == "darwin": + wx.Yield() def _rc_present_bitmap(self, *, data, format, **kwargs): # todo: we can easily support more formats here From 697608c0a1239f82b70800576518909e547868da Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 23 Jan 2026 17:08:00 +0100 Subject: [PATCH 36/50] Better moment to process events to reduce delay --- rendercanvas/_scheduler.py | 30 +++++++++--------------------- rendercanvas/base.py | 7 ++++++- tests/test_gui_glfw.py | 2 +- 3 files changed, 16 insertions(+), 23 deletions(-) diff --git a/rendercanvas/_scheduler.py b/rendercanvas/_scheduler.py index be24e880..6d13592e 100644 --- a/rendercanvas/_scheduler.py +++ b/rendercanvas/_scheduler.py @@ -160,19 +160,10 @@ async def __scheduler_task(self): else: raise RuntimeError(f"Unexpected scheduling mode: '{self._mode}'") - # Get canvas object or stop the loop + # Get canvas object or stop the loop. Make sure to delete this reference asap if (canvas := self.get_canvas()) is None: break - # Process events now. - # Note that we don't want to emit events *during* the draw, because event - # callbacks do stuff, and that stuff may include changing the canvas size, - # or affect layout in a UI application, all which are not recommended during - # the main draw-event (a.k.a. animation frame), and may even lead to errors. - # The one exception is resize events, which we do emit during a draw, if the - # size has changed since the last time that events were processed. - canvas._process_events() - if do_draw: # We do a draw and wait for the full draw to complete, including # the presentation (i.e. the 'consumption' of the frame), using @@ -189,17 +180,12 @@ async def __scheduler_task(self): # seem to be faster! Tested on MacOS M1 and Windows with NVidia # on the cube example. # - # A benefit of waiting for the presentation to be fully done - # before even processing the events for the next frame, is that - # the frame is as fresh as it can be, which means that for cases - # where the FPS is low because of a slow remote connection, the - # latency is minimized. - # - # It is possible that for cases where drawing is relatively - # slow, allowing the draw earlier can improve the FPS somewhat. - # Interestingly, you only want this for relatively high FPS to - # avoid latency. But then the FPS is likely already high enough, - # so why bother. + # Moreover, even if this 'interleaving' of drawing and + # presentation can improve the FPS in some cases, it would be at + # the cost of the delay between processing of user-events and + # the presentation of the corresponding frame to the user. In + # terms of user-experience, the cost of this delay is probably + # larger than the benefit of the potential fps increase. self._ready_for_present = Event() canvas._rc_request_draw() @@ -207,6 +193,8 @@ async def __scheduler_task(self): if self._ready_for_present: await self._ready_for_present.wait() else: + # If we have a non-drawing tick, at least process events. + canvas._process_events() del canvas # Note that when the canvas is closed, we may detect it here and break from the loop. diff --git a/rendercanvas/base.py b/rendercanvas/base.py index b8e798d6..28d16b8f 100644 --- a/rendercanvas/base.py +++ b/rendercanvas/base.py @@ -519,7 +519,12 @@ def _time_to_draw(self): _draw_and_present() -> ... -> finish_present() -> _rc_request_paint() """ - # TODO: process events here! + # This is an good time to process events, it's the moment that's closest to the actual draw + # as possible, but it's not in the native's paint-event (which is a bad moment to process events). + # Doing it now - as opposed to right before _rc_request_draw() - ensures that the rendered frame + # is up-to-date, which makes a huge difference for the perceived delay (e.g. for mouse movement) + # when the FPS is low on remote backends. + self._process_events() if self._present_to_screen: self._rc_request_paint() # -> _time_to_paint() -> _draw_and_present() diff --git a/tests/test_gui_glfw.py b/tests/test_gui_glfw.py index c2568063..2b82e8fd 100644 --- a/tests/test_gui_glfw.py +++ b/tests/test_gui_glfw.py @@ -136,7 +136,7 @@ def run_briefly(): device = wgpu.gpu.request_adapter_sync().request_device_sync() draw_frame1 = _get_draw_function(device, canvas) - allowed_frames = (1,) + allowed_frames = (1, 2) if os.getenv("CI"): allowed_frames = (1, 2, 3) From 0d3f78c7f9430f46c828ec4d9e7d7632e21e108e Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 23 Jan 2026 17:09:46 +0100 Subject: [PATCH 37/50] cleanup --- rendercanvas/contexts/wgpucontext.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/rendercanvas/contexts/wgpucontext.py b/rendercanvas/contexts/wgpucontext.py index 9663b442..5ead0a90 100644 --- a/rendercanvas/contexts/wgpucontext.py +++ b/rendercanvas/contexts/wgpucontext.py @@ -412,8 +412,6 @@ def do_sync_download(self, texture): self._awaitable.sync_wait() result = action.resolve() - if result is None: - breakpoint() assert result is not None if "data" in result: From 3c7368a05911e2e56de1e327df1b1264fbebf023 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 23 Jan 2026 17:12:59 +0100 Subject: [PATCH 38/50] restore example --- examples/demo.py | 4 ++-- rendercanvas/base.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/demo.py b/examples/demo.py index 08d32936..f5c28473 100644 --- a/examples/demo.py +++ b/examples/demo.py @@ -14,7 +14,7 @@ import time -from rendercanvas.pyside6 import RenderCanvas, loop +from rendercanvas.auto import RenderCanvas, loop from rendercanvas.utils.cube import setup_drawing_sync import rendercanvas @@ -24,7 +24,7 @@ title="Canvas events with $backend on $loop - $fps fps", max_fps=10, update_mode="continuous", - present_method="bitmap", + present_method="", ) diff --git a/rendercanvas/base.py b/rendercanvas/base.py index 28d16b8f..776b16cf 100644 --- a/rendercanvas/base.py +++ b/rendercanvas/base.py @@ -322,7 +322,7 @@ def get_context(self, context_type: str | type) -> contexts.BaseContext: # Get available present methods that the canvas can chose from. present_methods = list(context_class.present_methods) assert all(m in ("bitmap", "screen") for m in present_methods) # sanity check - if self._present_method is not None: + if self._present_method: if self._present_method not in present_methods: raise RuntimeError( f"Explicitly requested present_method {self._present_method!r} is not available for {context_name}." From cf0d40a4ea5e934799429a9c609441b1fa4a5b6e Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 23 Jan 2026 17:36:53 +0100 Subject: [PATCH 39/50] fix for pyqt6 --- rendercanvas/qt.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/rendercanvas/qt.py b/rendercanvas/qt.py index 2a01285e..e6643626 100644 --- a/rendercanvas/qt.py +++ b/rendercanvas/qt.py @@ -29,6 +29,7 @@ QtWidgets = importlib.import_module(".QtWidgets", libname) # Uncomment the line below to try QtOpenGLWidgets.QOpenGLWidget instead of QWidget # QtOpenGLWidgets = importlib.import_module(".QtOpenGLWidgets", libname) + WindowStateChange = QtCore.QEvent.Type.WindowStateChange if libname.startswith("PyQt"): # PyQt5 or PyQt6 WA_InputMethodEnabled = QtCore.Qt.WidgetAttribute.WA_InputMethodEnabled @@ -683,7 +684,7 @@ def changeEvent(self, event): # noqa: N802 # So we keep this implementation minimal, and leave it to the end-user if more sophisticated methods are needed. # Note that for present-method 'screen', this is not really needed, because Qt does not paint (i.e. animation frame) # when hidden. So this is mainly for when 'bitmap' mode is used. - if event.type() == QtCore.QEvent.WindowStateChange: + if event.type() == WindowStateChange: minimized = self.windowState() & WindowState.WindowMinimized self._subwidget._set_visible(not minimized) return super().changeEvent(event) From e799cdd2ff16f426dd0ca10de999672c43e9aa14 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Mon, 26 Jan 2026 13:43:55 +0100 Subject: [PATCH 40/50] Tweak api a bit --- rendercanvas/base.py | 16 +++---- rendercanvas/contexts/basecontext.py | 32 +++++++++++--- rendercanvas/contexts/bitmapcontext.py | 4 +- rendercanvas/contexts/wgpucontext.py | 60 +++++++++++++------------- tests/test_base.py | 6 ++- tests/test_context.py | 2 +- tests/test_scheduling.py | 8 +++- tests/test_sniffio.py | 6 ++- 8 files changed, 83 insertions(+), 51 deletions(-) diff --git a/rendercanvas/base.py b/rendercanvas/base.py index 776b16cf..fa00fa4e 100644 --- a/rendercanvas/base.py +++ b/rendercanvas/base.py @@ -529,7 +529,7 @@ def _time_to_draw(self): if self._present_to_screen: self._rc_request_paint() # -> _time_to_paint() -> _draw_and_present() else: - self._draw_and_present() + self._draw_and_present(force_sync=False) def _time_to_paint(self): """Callback for _rc_request_paint. @@ -540,9 +540,9 @@ def _time_to_paint(self): Errors are logged to the "rendercanvas" logger. """ if self._present_to_screen: - self._draw_and_present() + self._draw_and_present(force_sync=True) - def _draw_and_present(self, *, force_sync=False): + def _draw_and_present(self, *, force_sync: bool): """Draw the frame and init the presentation.""" # Re-entrant drawing is problematic. Let's actively prevent it. @@ -591,13 +591,13 @@ def _draw_and_present(self, *, force_sync=False): # Perform the presentation process. Might be async with log_exception("Present init error"): # Note: if vsync is used, this call may wait a little (happens down at the level of the driver or OS) - result = context._rc_present(force_sync=force_sync) - if result["method"] == "async": - assert not force_sync, "forced sync but got async present-result" - result["awaitable"].then(self._finish_present) - else: + if force_sync: + result = context._rc_present() self._finish_present(result) + else: + awaitable = context._rc_present_async() + awaitable.then(self._finish_present) finally: self.__is_drawing = False diff --git a/rendercanvas/contexts/basecontext.py b/rendercanvas/contexts/basecontext.py index 88d811d5..43eb9d86 100644 --- a/rendercanvas/contexts/basecontext.py +++ b/rendercanvas/contexts/basecontext.py @@ -88,7 +88,7 @@ def looks_like_hidpi(self) -> bool: """ return self._size_info["native_pixel_ratio"] >= 2.0 - def _rc_present(self, *, force_sync: bool = False): + def _rc_present(self) -> dict: """Called by BaseRenderCanvas to collect the result. Subclasses must implement this. The implementation should always return a present-result dict, which @@ -98,11 +98,6 @@ def _rc_present(self, *, force_sync: bool = False): * return ``{"method": "skip"}`` (special case). * If presentation could not be done for some reason: * return ``{"method": "fail", "message": "xx"}`` (special case). - * If the presenting is asynchronous: - * Return ``{"method": "async", "awaitable": xx}`` - * The 'awaitable' has a ``then(callback)`` method. - * The callback will be called with the actual result dictionary. - * If ``force_sync`` is True, this is not allowed. * If ``present_method`` is "screen": * Render to screen using the present info. * Return ``{"method", "screen"}`` as confirmation. @@ -115,11 +110,36 @@ def _rc_present(self, *, force_sync: bool = False): # This is a stub return {"method": "skip"} + def _rc_present_async(self) -> object: + """An async version of ``_rc_present()``. + + Must return an object that has a ``.then()`` method. + The default implementation simply calls ``_rc_present()`` and wraps the result in an awaitable-like object. + """ + result = self._rc_present() + return PseudoAwaitable(result) + def _rc_close(self): """Close context and release resources. Called by the canvas when it's closed.""" pass +class PseudoAwaitable: + __slots__ = ["_result"] + + def __init__(self, result): + self._result = result + + def then(self, callable): + callable(self._result) + + def __await__(self): + async def coro(): + return self._result + + return coro().__await__() + + class PseudoCanvasForWgpuPy: def __init__(self): self._physical_size = 0, 0 diff --git a/rendercanvas/contexts/bitmapcontext.py b/rendercanvas/contexts/bitmapcontext.py index eb385b17..f5cb59d8 100644 --- a/rendercanvas/contexts/bitmapcontext.py +++ b/rendercanvas/contexts/bitmapcontext.py @@ -81,7 +81,7 @@ def __init__(self, present_info): assert self._present_info["method"] == "bitmap" self._bitmap_and_format = None - def _rc_present(self, *, force_sync: bool = False): + def _rc_present(self): if self._bitmap_and_format is None: return {"method": "skip"} @@ -130,7 +130,7 @@ def __init__(self, present_info): self._create_wgpu_py_context() # sets self._wgpu_context self._wgpu_context_is_configured = False - def _rc_present(self, *, force_sync: bool = False): + def _rc_present(self): if self._bitmap_and_format is None: return {"method": "skip"} diff --git a/rendercanvas/contexts/wgpucontext.py b/rendercanvas/contexts/wgpucontext.py index 5ead0a90..6e775207 100644 --- a/rendercanvas/contexts/wgpucontext.py +++ b/rendercanvas/contexts/wgpucontext.py @@ -1,7 +1,7 @@ import time from typing import Sequence -from .basecontext import BaseContext +from .basecontext import BaseContext, PseudoAwaitable from .._coreutils import log_exception @@ -127,7 +127,7 @@ def get_current_texture(self) -> object: def _get_current_texture(self): raise NotImplementedError() - def _rc_present(self, *, force_sync: bool = False) -> None: + def _rc_present(self) -> dict: """Hook for the canvas to present the rendered result. Present what has been drawn to the current texture, by compositing it to the @@ -135,6 +135,9 @@ def _rc_present(self, *, force_sync: bool = False) -> None: """ raise NotImplementedError() + def _rc_present_async(self): + return super()._rc_present_async() + class WgpuContextToScreen(WgpuContext): """A wgpu context that present directly to a ``wgpu.GPUCanvasContext``. @@ -162,7 +165,7 @@ def _unconfigure(self) -> None: def _get_current_texture(self) -> object: return self._wgpu_context.get_current_texture() - def _rc_present(self, *, force_sync: bool = False) -> None: + def _rc_present(self) -> dict: self._wgpu_context.present() return {"method": "screen"} @@ -314,20 +317,24 @@ def _get_current_texture(self): return self._texture - def _rc_present(self, *, force_sync: bool = False) -> None: + def _rc_present(self) -> dict: if not self._texture: return {"method": "skip"} # Select new downloader downloader = self._downloaders[-1] - if force_sync: - return downloader.do_sync_download(self._texture) - else: - awaitable = downloader.initiate_download(self._texture) - return {"method": "async", "awaitable": awaitable} + return downloader.do_sync_download(self._texture) + + def _rc_present_async(self): + if not self._texture: + return PseudoAwaitable({"method": "skip"}) + + # Select new downloader + downloader = self._downloaders[-1] - # downloader._awaitable + awaitable = downloader.initiate_download(self._texture) + return awaitable def _rc_close(self): self._drop_texture() @@ -356,15 +363,19 @@ def __init__(self, device, buffer_usage): self._action = None self._time_since_size_ok = 0 + def _clear_pending_download(self): + if self._action is not None: + self._action.cancel() + if self._awaitable is not None: + if self._buffer.map_state == "pending": + self._awaitable.sync_wait() + if self._buffer.map_state == "mapped": + self._buffer.unmap() + def _get_awaitable_for_download(self, texture): - # Check state - if self._action is not None and self._action.is_pending(): - map_state = "none" - if self._buffer is not None: - map_state = self._buffer.map_state - raise RuntimeError( - f"Attempt to initiate texture download when previous frame is not yet consumed (buffer map_state {map_state!r})." - ) + # First clear any pending downloads. + # This covers cases when switching between ``force_draw()`` normal rendering. + self._clear_pending_download() # Create new action object and make sure that the buffer is the correct size action = AsyncImageDownloadAction(texture) @@ -384,22 +395,10 @@ def _get_awaitable_for_download(self, texture): def initiate_download(self, texture): self._get_awaitable_for_download(texture) - - # When the buffer maps, we continue the download/present action self._awaitable.then(self._action.resolve) - return self._action def do_sync_download(self, texture): - # First clear any pending downloads - if self._action is not None: - self._action.cancel() - if self._awaitable is not None: - if self._buffer.map_state == "pending": - self._awaitable.sync_wait() - if self._buffer.map_state == "mapped": - self._buffer.unmap() - # Start a fresh download self._get_awaitable_for_download(texture) @@ -416,6 +415,7 @@ def do_sync_download(self, texture): if "data" in result: data = result["data"] + # We must make a copy of the data, because it may belong to a mapped buffer result["data"] = memoryview(bytearray(data)).cast(data.format, data.shape) return result diff --git a/tests/test_base.py b/tests/test_base.py index 1d98d21f..5bd49164 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -4,6 +4,7 @@ import numpy as np import rendercanvas +from rendercanvas.contexts.basecontext import PseudoAwaitable from testutils import run_tests, can_use_wgpu_lib from pytest import mark @@ -13,9 +14,12 @@ def test_base_canvas_context(): class StubContext: - def _rc_present(self, *, force_sync=False): + def _rc_present(self): return {"method": "skip"} + def _rc_present_async(self): + return PseudoAwaitable(self._rc_present()) + class CanvasThatRaisesErrorsDuringDrawing(rendercanvas.BaseRenderCanvas): def __init__(self): diff --git a/tests/test_context.py b/tests/test_context.py index 2afb0b8c..767acfdc 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -56,7 +56,7 @@ def set_physical_size(self, w, h): self._rc_set_size_dict(size_info) def present(self): - return self._rc_present(force_sync=True) + return self._rc_present() def close(self): self._rc_close() diff --git a/tests/test_scheduling.py b/tests/test_scheduling.py index a42b609c..d08aa407 100644 --- a/tests/test_scheduling.py +++ b/tests/test_scheduling.py @@ -10,6 +10,7 @@ from testutils import run_tests from rendercanvas.base import BaseCanvasGroup, BaseRenderCanvas from rendercanvas.offscreen import StubLoop +from rendercanvas.contexts.basecontext import PseudoAwaitable class MyCanvasGroup(BaseCanvasGroup): @@ -23,9 +24,12 @@ class MyLoop(StubLoop): class StubContext: - def _rc_present(self, *, force_sync=False): + def _rc_present(self): return {"method": "skip"} + def _rc_present_async(self): + return PseudoAwaitable(self._rc_present()) + class MyCanvas(BaseRenderCanvas): _rc_canvas_group = MyCanvasGroup(MyLoop()) @@ -49,7 +53,7 @@ def _process_events(self): self.events_count += 1 return super()._process_events() - def _draw_and_present(self, *, force_sync=False): + def _draw_and_present(self, *, force_sync): super()._draw_and_present(force_sync=force_sync) self.draw_count += 1 diff --git a/tests/test_sniffio.py b/tests/test_sniffio.py index 04cbdbf6..124b4b6e 100644 --- a/tests/test_sniffio.py +++ b/tests/test_sniffio.py @@ -11,6 +11,7 @@ import rendercanvas from rendercanvas.base import BaseCanvasGroup, BaseRenderCanvas from rendercanvas.asyncio import loop as asyncio_loop +from rendercanvas.contexts.basecontext import PseudoAwaitable from rendercanvas.asyncio import AsyncioLoop from rendercanvas.trio import TrioLoop @@ -25,9 +26,12 @@ class CanvasGroup(BaseCanvasGroup): class StubContext: - def _rc_present(self, *, force_sync=False): + def _rc_present(self): return {"method": "skip"} + def _rc_present_async(self): + return PseudoAwaitable(self._rc_present()) + class RealRenderCanvas(BaseRenderCanvas): _rc_canvas_group = CanvasGroup(asyncio_loop) From 7286e95f0f19eb86b32e24f63d784ff57b928fa5 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Mon, 26 Jan 2026 13:52:06 +0100 Subject: [PATCH 41/50] Just one downloader --- rendercanvas/contexts/wgpucontext.py | 44 ++++++++++------------------ 1 file changed, 15 insertions(+), 29 deletions(-) diff --git a/rendercanvas/contexts/wgpucontext.py b/rendercanvas/contexts/wgpucontext.py index 6e775207..04e7ce58 100644 --- a/rendercanvas/contexts/wgpucontext.py +++ b/rendercanvas/contexts/wgpucontext.py @@ -191,21 +191,18 @@ def __init__(self, present_info: dict): self._capabilities = self._get_capabilities() # The current texture to render to. Is replaced when the canvas resizes. + # We have a single texture (not a ring of textures), because copying the + # contents to a download-buffer is near-instant. 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. - # - # It looks like a single 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 the - # same time. Let's leave the ring-mechanism in-place for now, so we can - # experiment with it. - # TODO: refactor to just one downloader, making the code a bit simpler - self._downloaders = [None] # Put as many None's as you want buffers + # Object to download the rendered images to the CPU/RAM. Mapping the + # buffers to RAM takes time, and we want to wait for this + # asynchronously. We could have a ring of buffers to allow multiple + # concurrent downloads (start downloading the next frame before the + # previous is done downloading), but from what we've observed, this does + # not improve the FPS. It does costs memory though, and can actually + # strain the GPU more. + self._downloader = None def _get_capabilities(self): """Get dict of capabilities and cache the result.""" @@ -284,15 +281,12 @@ 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._context_buffer_usage) - for _ in self._downloaders - ] + # (re)create downloader + self._downloader = ImageDownloader(config["device"], self._context_buffer_usage) def _unconfigure(self) -> None: self._drop_texture() - self._downloaders[:] = [None for _ in self._downloaders] + self._downloader = None def _get_current_texture(self): # When the texture is active right now, we could either: @@ -320,20 +314,12 @@ def _get_current_texture(self): def _rc_present(self) -> dict: if not self._texture: return {"method": "skip"} - - # Select new downloader - downloader = self._downloaders[-1] - - return downloader.do_sync_download(self._texture) + return self._downloader.do_sync_download(self._texture) def _rc_present_async(self): if not self._texture: return PseudoAwaitable({"method": "skip"}) - - # Select new downloader - downloader = self._downloaders[-1] - - awaitable = downloader.initiate_download(self._texture) + awaitable = self._downloader.initiate_download(self._texture) return awaitable def _rc_close(self): From 5ab755c2756a9e2ddd651b92292259f409d81e63 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 27 Jan 2026 12:08:34 +0100 Subject: [PATCH 42/50] Clean up low-level presentation --- pyproject.toml | 2 +- rendercanvas/contexts/basecontext.py | 5 + rendercanvas/contexts/wgpucontext.py | 171 ++++++++++++++++----------- rendercanvas/qt.py | 11 +- 4 files changed, 120 insertions(+), 69 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1162a476..9fce626a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ keywords = [ "jupyter", ] requires-python = ">= 3.10" -dependencies = [] # Zero hard dependencies! +dependencies = ['numpy'] # Numpy is the only hard dependency [project.optional-dependencies] # For users jupyter = ["jupyter_rfb>=0.4.2"] diff --git a/rendercanvas/contexts/basecontext.py b/rendercanvas/contexts/basecontext.py index 43eb9d86..38f29bc1 100644 --- a/rendercanvas/contexts/basecontext.py +++ b/rendercanvas/contexts/basecontext.py @@ -21,6 +21,8 @@ def __init__(self, present_info: dict): } self._object_with_physical_size = None # to support old wgpu-py api self._wgpu_context = None + # Configuration dict for the backend to influence the present process (only for bitmap present) + self._present_params = {} def __repr__(self): return f"" @@ -119,6 +121,9 @@ def _rc_present_async(self) -> object: result = self._rc_present() return PseudoAwaitable(result) + def _rc_set_present_params(self, **present_params): + self._present_params = present_params + def _rc_close(self): """Close context and release resources. Called by the canvas when it's closed.""" pass diff --git a/rendercanvas/contexts/wgpucontext.py b/rendercanvas/contexts/wgpucontext.py index 04e7ce58..6afd5ae8 100644 --- a/rendercanvas/contexts/wgpucontext.py +++ b/rendercanvas/contexts/wgpucontext.py @@ -1,6 +1,8 @@ import time from typing import Sequence +import numpy as np + from .basecontext import BaseContext, PseudoAwaitable from .._coreutils import log_exception @@ -314,12 +316,14 @@ def _get_current_texture(self): def _rc_present(self) -> dict: if not self._texture: return {"method": "skip"} - return self._downloader.do_sync_download(self._texture) + return self._downloader.do_sync_download(self._texture, self._present_params) def _rc_present_async(self): if not self._texture: return PseudoAwaitable({"method": "skip"}) - awaitable = self._downloader.initiate_download(self._texture) + awaitable = self._downloader.initiate_download( + self._texture, self._present_params + ) return awaitable def _rc_close(self): @@ -358,13 +362,13 @@ def _clear_pending_download(self): if self._buffer.map_state == "mapped": self._buffer.unmap() - def _get_awaitable_for_download(self, texture): + def _get_awaitable_for_download(self, texture, present_params=None): # First clear any pending downloads. # This covers cases when switching between ``force_draw()`` normal rendering. self._clear_pending_download() # Create new action object and make sure that the buffer is the correct size - action = AsyncImageDownloadAction(texture) + action = AsyncImageDownloadAction(texture, present_params) stride, nbytes = action.stride, action.nbytes self._ensure_buffer_size(nbytes) action.set_buffer(self._buffer) @@ -379,18 +383,18 @@ def _get_awaitable_for_download(self, texture): self._action = action self._awaitable = awaitable - def initiate_download(self, texture): - self._get_awaitable_for_download(texture) + def initiate_download(self, texture, present_params): + self._get_awaitable_for_download(texture, present_params) self._awaitable.then(self._action.resolve) return self._action - def do_sync_download(self, texture): + def do_sync_download(self, texture, present_params): # Start a fresh download self._get_awaitable_for_download(texture) # With a fresh action self._action.cancel() - action = AsyncImageDownloadAction(texture) + action = AsyncImageDownloadAction(texture, present_params) action.set_buffer(self._buffer) # Async-wait, then resolve @@ -452,8 +456,9 @@ def _queue_command_to_copy_texture(self, texture, stride): class AsyncImageDownloadAction: """Single-use image download helper object that has a 'then' method (i.e. follows the awaitable pattern a bit).""" - def __init__(self, texture): + def __init__(self, texture, present_params): self._callbacks = [] + self._present_params = present_params # The image is stored in wgpu buffer, which needs to get mapped before we can read the bitmap self._buffer = None # We examine the texture to understand what the bitmap will look like @@ -480,20 +485,18 @@ def resolve(self, _=None): self._buffer = None try: - # Note: The bitmap array is only valid within this try-context; - # once the buffer unmaps, the underlying memory is freed, attempting to access it can result in a segfault. - mapped_bitmap = self._get_bitmap(buffer) - result = { - "method": "bitmap", - "format": "rgba-u8", - "data": mapped_bitmap, - } - for callback in self._callbacks: - callback(result) - self._callbacks = [] - return result + data = self._get_bitmap(buffer) finally: buffer.unmap() + result = { + "method": "bitmap", + "format": "rgba-u8", + "data": data, + } + for callback in self._callbacks: + callback(result) + self._callbacks = [] + return result def _parse_texture_metadata(self, texture): size = texture.size @@ -513,26 +516,24 @@ def _parse_texture_metadata(self, texture): f"Image present unsupported texture format bitdepth {format}." ) - memoryview_type = "B" + dtype = "uint8" if "float" in format: - memoryview_type = "e" if "16" in format else "f" + dtype = "float16" if "16" in format else "float32" else: + dtype = "int" if "sint" in format else "uint" if "32" in format: - memoryview_type = "I" + dtype += "32" elif "16" in format: - memoryview_type = "H" + dtype += "16" else: - memoryview_type = "B" - if "sint" in format: - memoryview_type = memoryview_type.lower() + dtype += "8" plain_stride = bytes_per_pixel * size[0] extra_stride = (256 - plain_stride % 256) % 256 padded_stride = plain_stride + extra_stride - self._memoryview_type = memoryview_type + self._dtype = dtype self._nchannels = nchannels - self._plain_stride = plain_stride self._padded_stride = padded_stride self._texture_size = size @@ -541,52 +542,88 @@ def _parse_texture_metadata(self, texture): self.nbytes = padded_stride * size[1] def _get_bitmap(self, buffer): - memoryview_type = self._memoryview_type + dtype = self._dtype nchannels = self._nchannels - plain_stride = self._plain_stride padded_stride = self._padded_stride size = self._texture_size plain_shape = (size[1], size[0], nchannels) + present_params = self._present_params or {} + + # Read bitmap from mapped buffer. Note that the data is mapped, so we + # *must* copy the data before unmapping the buffer. By applying any + # processing *here* while the buffer is mapped, we can avoid one + # data-copy. E.g. we can create a numpy view on the data and then copy + # *that*, rather than copying the raw data and then making another copy + # to make it contiguous. + + # Note that this code here is the main reason for having numpy as a + # dependency: with just memoryview (no numpy), we cannot create a + # strided array, and we cannot copy strided data without iterating over + # all rows. - # Download from mappable buffer - # Because we use `copy=False``, we *must* copy the data. + # Get array if buffer.map_state == "pending": raise RuntimeError("Buffer state is 'pending' in get_bitmap()") mapped_data = buffer.read_mapped(copy=False) - # TODO: return mapped data - # TODO: can we pass the mapped data downstream without copying it, i.e. before unmapping the buffer? Saves another copy. - - # Copy the data - if padded_stride > plain_stride: - # Copy per row - nbytes_clean = plain_shape[0] * plain_shape[1] * plain_shape[2] - data = memoryview(bytearray(nbytes_clean)).cast(mapped_data.format) - i_start = 0 - for i in range(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 + # Determine how to process it. + submethod = present_params.get("submethod", "contiguous-array") + + # Triage. AK: I implemented some stubs, primarily as an indication of + # how I see this scaling out to more sophisticated methods. Here we can + # add diff images, gpu-based pseudo-jpeg, etc. + + if submethod == "contiguous-array": + # This is the default. + + # Wrap the data in a (possible strided) numpy array + data = np.asarray(mapped_data, dtype=dtype).reshape( + plain_shape[0], padded_stride // nchannels, nchannels + ) + # Make a copy, making it contiguous. + data = data[:, : plain_shape[1], :].copy() + + elif submethod == "strided-array": + # In some cases it could be beneficial to use the data that has 256-byte aligned rows, + # e.g. when the data must be uploaded to a GPU again. + + # Wrap the data in a (possible strided) numpy array + data = np.asarray(mapped_data, dtype=dtype).reshape( + plain_shape[0], padded_stride // nchannels, nchannels + ) + # Make a copy of the strided data, and create a view on that. + data = data.copy()[:, : plain_shape[1], :] + + elif submethod == "jpeg": + # For now just a stub, activate in the upcoming Anywidget backend + + import simplejpeg + + # Get strided array view + data = np.asarray(mapped_data, dtype=dtype).reshape( + plain_shape[0], padded_stride // nchannels, nchannels + ) + data = data[:, : plain_shape[1], :] + + # Encode jpeg on the mapped data + data = simplejpeg.encode_jpeg( + data, + present_params.get("quality", 85), + "rgba", + present_params.get("subsampling", "420"), + fastdct=True, + ) + + elif submethod == "png": + # For cases where lossless compression is needed. We can easily do this in pure Python, I have code for that somewhere. + raise NotImplementedError("present submethod 'png'") + + elif submethod == "gpu-jpeg": + # jpeg encoding on the GPU, produces pseudo-jpeg that needs to be decoded with a special shader at the receiving end. + # This implementation is more work, because we need to setup a GPU pipeline with multiple compute shaders. + raise NotImplementedError("present submethod 'gpu-jpeg'") + 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(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) - - # Represent as memory object to avoid numpy dependency - # Equivalent: np.frombuffer(data, np.uint8).reshape(plain_shape) - data = data.cast(memoryview_type, plain_shape) - - # Note, no unmap yet! + raise RuntimeError(f"Unknown present submethod {submethod!r}") return data diff --git a/rendercanvas/qt.py b/rendercanvas/qt.py index e6643626..7264ccd1 100644 --- a/rendercanvas/qt.py +++ b/rendercanvas/qt.py @@ -285,6 +285,7 @@ def __init__(self, *args, **kwargs): self._last_image = None self._last_winid = None self._is_closed = False + self._pending_present_params = None self.setAutoFillBackground(False) self.setAttribute(WA_DeleteOnClose, True) @@ -392,6 +393,7 @@ def _rc_get_present_info(self, present_methods): else: return None elif the_method == "bitmap": + self._pending_present_params = {"submethod": "contiguous-array"} return { "method": "bitmap", "formats": list(BITMAP_FORMAT_MAP.keys()), @@ -400,6 +402,9 @@ def _rc_get_present_info(self, present_methods): return None # raises error def _rc_request_draw(self): + if self._pending_present_params: + self._canvas_context._rc_set_present_params(**self._pending_present_params) + self._pending_present_params = None self._time_to_draw() def _rc_request_paint(self): @@ -451,11 +456,15 @@ def _rc_present_bitmap(self, *, data, format, **kwargs): width, height = data.shape[1], data.shape[0] # width, height + # Use the given array, or its base, if this is a strided view + # so that we can also work with submethod 'strided-array'. + thedata = data if data.base is None else data.base + # Wrap the data in a QImage (no copy, so we need to keep a ref to data) qtformat = BITMAP_FORMAT_MAP[format] bytes_per_line = data.strides[0] self._last_image = ( - QtGui.QImage(data, width, height, bytes_per_line, qtformat), + QtGui.QImage(thedata, width, height, bytes_per_line, qtformat), data, ) From 6abd8c1b94ed230a559a48e0eadbf2b02385b813 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 27 Jan 2026 12:16:09 +0100 Subject: [PATCH 43/50] fixes --- rendercanvas/contexts/wgpucontext.py | 7 ------- rendercanvas/offscreen.py | 4 +--- tests/test_context.py | 3 +-- tests/test_meta.py | 2 +- 4 files changed, 3 insertions(+), 13 deletions(-) diff --git a/rendercanvas/contexts/wgpucontext.py b/rendercanvas/contexts/wgpucontext.py index 6afd5ae8..dc78642f 100644 --- a/rendercanvas/contexts/wgpucontext.py +++ b/rendercanvas/contexts/wgpucontext.py @@ -400,14 +400,7 @@ def do_sync_download(self, texture, present_params): # Async-wait, then resolve self._awaitable.sync_wait() result = action.resolve() - assert result is not None - - if "data" in result: - data = result["data"] - # We must make a copy of the data, because it may belong to a mapped buffer - result["data"] = memoryview(bytearray(data)).cast(data.format, data.shape) - return result def _ensure_buffer_size(self, required_size): diff --git a/rendercanvas/offscreen.py b/rendercanvas/offscreen.py index 43245fb0..73d9203b 100644 --- a/rendercanvas/offscreen.py +++ b/rendercanvas/offscreen.py @@ -121,9 +121,7 @@ def set_pixel_ratio(self, pixel_ratio: float): def draw(self): """Perform a draw and get the resulting image. - The image array is returned as an NxMx4 memoryview object. - This object can be converted to a numpy array (without copying data) - using ``np.asarray(arr)``. + The image is returned as a contiguous NxMx4 numpy array. """ self.force_draw() return self._last_image diff --git a/tests/test_context.py b/tests/test_context.py index 767acfdc..323ae578 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -266,8 +266,7 @@ def test_wgpu_context(): # is in the sRGB colorspace. result = canvas.draw() - assert isinstance(result, memoryview) - result = np.asarray(result) + assert isinstance(result, np.ndarray) assert np.all(result == bitmap) # Now we change the size diff --git a/tests/test_meta.py b/tests/test_meta.py index 468bb831..e1f55845 100644 --- a/tests/test_meta.py +++ b/tests/test_meta.py @@ -77,7 +77,7 @@ def test_namespace(): def test_deps_plain_import(): modules = get_loaded_modules("rendercanvas", 1) modules.discard("sniffio") # sniffio is imported if available - assert modules == {"rendercanvas"} + assert modules == {"rendercanvas", "numpy"} # Note, no wgpu From fb40c2f863d8c46991da63861ed98144c1f5a06a Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 27 Jan 2026 12:37:40 +0100 Subject: [PATCH 44/50] Undo earlier change --- rendercanvas/base.py | 10 +++++--- rendercanvas/contexts/basecontext.py | 32 +++++--------------------- rendercanvas/contexts/bitmapcontext.py | 4 ++-- rendercanvas/contexts/wgpucontext.py | 29 +++++++++++------------ tests/test_base.py | 6 +---- tests/test_context.py | 2 +- tests/test_scheduling.py | 6 +---- tests/test_sniffio.py | 6 +---- 8 files changed, 32 insertions(+), 63 deletions(-) diff --git a/rendercanvas/base.py b/rendercanvas/base.py index fa00fa4e..362a2a92 100644 --- a/rendercanvas/base.py +++ b/rendercanvas/base.py @@ -593,11 +593,15 @@ def _draw_and_present(self, *, force_sync: bool): # Note: if vsync is used, this call may wait a little (happens down at the level of the driver or OS) if force_sync: - result = context._rc_present() + result = context._rc_present(force_sync=True) + assert result["method"] != "async" self._finish_present(result) else: - awaitable = context._rc_present_async() - awaitable.then(self._finish_present) + result = context._rc_present() + if result["method"] == "async": + result["awaitable"].then(self._finish_present) + else: + self._finish_present(result) finally: self.__is_drawing = False diff --git a/rendercanvas/contexts/basecontext.py b/rendercanvas/contexts/basecontext.py index 38f29bc1..2c50785d 100644 --- a/rendercanvas/contexts/basecontext.py +++ b/rendercanvas/contexts/basecontext.py @@ -90,12 +90,17 @@ def looks_like_hidpi(self) -> bool: """ return self._size_info["native_pixel_ratio"] >= 2.0 - def _rc_present(self) -> dict: + def _rc_present(self, *, force_sync: bool = False) -> dict: """Called by BaseRenderCanvas to collect the result. Subclasses must implement this. The implementation should always return a present-result dict, which should have at least a field 'method'. + For async cases, can return ``{"method": "async", "awaitable": awaitables}``, + where ``awaitable.then(callback)`` can be used to schedule a callback + being called when the present process is done. This is not allowed when + ``force_sync`` is set. + * If there is nothing to present, e.g. because nothing was rendered yet: * return ``{"method": "skip"}`` (special case). * If presentation could not be done for some reason: @@ -112,15 +117,6 @@ def _rc_present(self) -> dict: # This is a stub return {"method": "skip"} - def _rc_present_async(self) -> object: - """An async version of ``_rc_present()``. - - Must return an object that has a ``.then()`` method. - The default implementation simply calls ``_rc_present()`` and wraps the result in an awaitable-like object. - """ - result = self._rc_present() - return PseudoAwaitable(result) - def _rc_set_present_params(self, **present_params): self._present_params = present_params @@ -129,22 +125,6 @@ def _rc_close(self): pass -class PseudoAwaitable: - __slots__ = ["_result"] - - def __init__(self, result): - self._result = result - - def then(self, callable): - callable(self._result) - - def __await__(self): - async def coro(): - return self._result - - return coro().__await__() - - class PseudoCanvasForWgpuPy: def __init__(self): self._physical_size = 0, 0 diff --git a/rendercanvas/contexts/bitmapcontext.py b/rendercanvas/contexts/bitmapcontext.py index f5cb59d8..6f4b3748 100644 --- a/rendercanvas/contexts/bitmapcontext.py +++ b/rendercanvas/contexts/bitmapcontext.py @@ -81,7 +81,7 @@ def __init__(self, present_info): assert self._present_info["method"] == "bitmap" self._bitmap_and_format = None - def _rc_present(self): + def _rc_present(self, *, force_sync: bool = False) -> dict: if self._bitmap_and_format is None: return {"method": "skip"} @@ -130,7 +130,7 @@ def __init__(self, present_info): self._create_wgpu_py_context() # sets self._wgpu_context self._wgpu_context_is_configured = False - def _rc_present(self): + def _rc_present(self, *, force_sync: bool = False) -> dict: if self._bitmap_and_format is None: return {"method": "skip"} diff --git a/rendercanvas/contexts/wgpucontext.py b/rendercanvas/contexts/wgpucontext.py index dc78642f..5d596288 100644 --- a/rendercanvas/contexts/wgpucontext.py +++ b/rendercanvas/contexts/wgpucontext.py @@ -3,7 +3,7 @@ import numpy as np -from .basecontext import BaseContext, PseudoAwaitable +from .basecontext import BaseContext from .._coreutils import log_exception @@ -129,7 +129,7 @@ def get_current_texture(self) -> object: def _get_current_texture(self): raise NotImplementedError() - def _rc_present(self) -> dict: + def _rc_present(self, *, force_sync: bool = False) -> dict: """Hook for the canvas to present the rendered result. Present what has been drawn to the current texture, by compositing it to the @@ -137,9 +137,6 @@ def _rc_present(self) -> dict: """ raise NotImplementedError() - def _rc_present_async(self): - return super()._rc_present_async() - class WgpuContextToScreen(WgpuContext): """A wgpu context that present directly to a ``wgpu.GPUCanvasContext``. @@ -167,7 +164,7 @@ def _unconfigure(self) -> None: def _get_current_texture(self) -> object: return self._wgpu_context.get_current_texture() - def _rc_present(self) -> dict: + def _rc_present(self, *, force_sync: bool = False) -> dict: self._wgpu_context.present() return {"method": "screen"} @@ -313,18 +310,18 @@ def _get_current_texture(self): return self._texture - def _rc_present(self) -> dict: + def _rc_present(self, *, force_sync: bool = False) -> dict: if not self._texture: return {"method": "skip"} - return self._downloader.do_sync_download(self._texture, self._present_params) - - def _rc_present_async(self): - if not self._texture: - return PseudoAwaitable({"method": "skip"}) - awaitable = self._downloader.initiate_download( - self._texture, self._present_params - ) - return awaitable + if force_sync: + return self._downloader.do_sync_download( + self._texture, self._present_params + ) + else: + awaitable = self._downloader.initiate_download( + self._texture, self._present_params + ) + return {"method": "async", "awaitable": awaitable} def _rc_close(self): self._drop_texture() diff --git a/tests/test_base.py b/tests/test_base.py index 5bd49164..6625542e 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -4,7 +4,6 @@ import numpy as np import rendercanvas -from rendercanvas.contexts.basecontext import PseudoAwaitable from testutils import run_tests, can_use_wgpu_lib from pytest import mark @@ -14,12 +13,9 @@ def test_base_canvas_context(): class StubContext: - def _rc_present(self): + def _rc_present(self, force_sync=False): return {"method": "skip"} - def _rc_present_async(self): - return PseudoAwaitable(self._rc_present()) - class CanvasThatRaisesErrorsDuringDrawing(rendercanvas.BaseRenderCanvas): def __init__(self): diff --git a/tests/test_context.py b/tests/test_context.py index 323ae578..80899713 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -56,7 +56,7 @@ def set_physical_size(self, w, h): self._rc_set_size_dict(size_info) def present(self): - return self._rc_present() + return self._rc_present(force_sync=True) def close(self): self._rc_close() diff --git a/tests/test_scheduling.py b/tests/test_scheduling.py index d08aa407..762044c6 100644 --- a/tests/test_scheduling.py +++ b/tests/test_scheduling.py @@ -10,7 +10,6 @@ from testutils import run_tests from rendercanvas.base import BaseCanvasGroup, BaseRenderCanvas from rendercanvas.offscreen import StubLoop -from rendercanvas.contexts.basecontext import PseudoAwaitable class MyCanvasGroup(BaseCanvasGroup): @@ -24,12 +23,9 @@ class MyLoop(StubLoop): class StubContext: - def _rc_present(self): + def _rc_present(self, force_sync=False): return {"method": "skip"} - def _rc_present_async(self): - return PseudoAwaitable(self._rc_present()) - class MyCanvas(BaseRenderCanvas): _rc_canvas_group = MyCanvasGroup(MyLoop()) diff --git a/tests/test_sniffio.py b/tests/test_sniffio.py index 124b4b6e..fd14a814 100644 --- a/tests/test_sniffio.py +++ b/tests/test_sniffio.py @@ -11,7 +11,6 @@ import rendercanvas from rendercanvas.base import BaseCanvasGroup, BaseRenderCanvas from rendercanvas.asyncio import loop as asyncio_loop -from rendercanvas.contexts.basecontext import PseudoAwaitable from rendercanvas.asyncio import AsyncioLoop from rendercanvas.trio import TrioLoop @@ -26,12 +25,9 @@ class CanvasGroup(BaseCanvasGroup): class StubContext: - def _rc_present(self): + def _rc_present(self, force_sync=False): return {"method": "skip"} - def _rc_present_async(self): - return PseudoAwaitable(self._rc_present()) - class RealRenderCanvas(BaseRenderCanvas): _rc_canvas_group = CanvasGroup(asyncio_loop) From d9007eeb4e4966638d889318cdabcb8f05886eec Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 27 Jan 2026 13:15:20 +0100 Subject: [PATCH 45/50] Also use numpy in bitmap contexts --- rendercanvas/contexts/_fullscreen.py | 2 ++ rendercanvas/contexts/basecontext.py | 3 +- rendercanvas/contexts/bitmapcontext.py | 43 +++++++++++++++----------- tests/test_context.py | 3 +- 4 files changed, 30 insertions(+), 21 deletions(-) diff --git a/rendercanvas/contexts/_fullscreen.py b/rendercanvas/contexts/_fullscreen.py index 8429b917..948e5d02 100644 --- a/rendercanvas/contexts/_fullscreen.py +++ b/rendercanvas/contexts/_fullscreen.py @@ -1,5 +1,7 @@ import wgpu +# Note: we can now use numpy here too, which may make things a bit easier. On the other hand, this works now. + class FullscreenTexture: """An object that helps rendering a texture to the full viewport.""" diff --git a/rendercanvas/contexts/basecontext.py b/rendercanvas/contexts/basecontext.py index 2c50785d..c406e6b7 100644 --- a/rendercanvas/contexts/basecontext.py +++ b/rendercanvas/contexts/basecontext.py @@ -110,8 +110,9 @@ def _rc_present(self, *, force_sync: bool = False) -> dict: * Return ``{"method", "screen"}`` as confirmation. * If ``present_method`` is "bitmap": * Return ``{"method": "bitmap", "data": data, "format": format}``. - * 'data' is a memoryview, or something that can be converted to a memoryview, like a numpy array. + * 'data' is a numpy array. * 'format' is the format of the bitmap, must be in ``present_info['formats']`` ("rgba-u8" is always supported). + * In the future more variations on the "bitmap" present method will be made possible. """ # This is a stub diff --git a/rendercanvas/contexts/bitmapcontext.py b/rendercanvas/contexts/bitmapcontext.py index 6f4b3748..61c3694f 100644 --- a/rendercanvas/contexts/bitmapcontext.py +++ b/rendercanvas/contexts/bitmapcontext.py @@ -1,5 +1,8 @@ from .basecontext import BaseContext +import numpy as np + + __all__ = ["BitmapContext", "BitmapContextToBitmap", "BitmapContextToScreen"] @@ -36,39 +39,39 @@ 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 - converted to a memoryview, like a numpy array. It must represent a 2D - image in either grayscale or rgba format, with uint8 values + converted to a numpy array. It must represent a 2D image in either + grayscale or rgba format, with uint8 values """ - m = memoryview(bitmap) + arr = np.asarray(bitmap) # Check dtype - if m.format == "B": + if arr.dtype == np.uint8: dtype = "u8" else: raise ValueError( - "Unsupported bitmap dtype/format '{m.format}', expecting unsigned bytes ('B')." + "Unsupported bitmap dtype '{arr.dtype}', expecting unsigned bytes." ) # Get color format color_format = None - if len(m.shape) == 2: + if arr.ndim == 2: color_format = "i" - elif len(m.shape) == 3: - if m.shape[2] == 1: + elif arr.ndim == 3: + if arr.shape[2] == 1: color_format = "i" - elif m.shape[2] == 4: + elif arr.shape[2] == 4: color_format = "rgba" if not color_format: raise ValueError( - f"Unsupported bitmap shape {m.shape}, expecting a 2D grayscale or rgba image." + f"Unsupported bitmap shape {arr.shape}, expecting a 2D grayscale or rgba image." ) # We should now have one of two formats format = f"{color_format}-{dtype}" assert format in ("rgba-u8", "i-u8") - self._bitmap_and_format = m, format + self._bitmap_and_format = arr, format class BitmapContextToBitmap(BitmapContext): @@ -86,17 +89,19 @@ def _rc_present(self, *, force_sync: bool = False) -> dict: return {"method": "skip"} bitmap, format = self._bitmap_and_format + if format not in self._present_info["formats"]: # Convert from i-u8 -> rgba-u8. This surely hurts performance. assert format == "i-u8" - flat_bitmap = bitmap.cast("B", (bitmap.nbytes,)) - new_bitmap = memoryview(bytearray(bitmap.nbytes * 4)).cast("B") - new_bitmap[::4] = flat_bitmap - new_bitmap[1::4] = flat_bitmap - new_bitmap[2::4] = flat_bitmap - new_bitmap[3::4] = b"\xff" * flat_bitmap.nbytes - bitmap = new_bitmap.cast("B", (*bitmap.shape, 4)) + new_bitmap = np.full((*bitmap.shape[:2], 4), 255, dtype=np.uint8) + new_bitmap[:, :, 0] = bitmap + new_bitmap[:, :, 1] = bitmap + new_bitmap[:, :, 2] = bitmap + bitmap = new_bitmap format = "rgba-u8" + elif not bitmap.flags.contiguous: + bitmap = bitmap.copy() + return { "method": "bitmap", "data": bitmap, @@ -138,6 +143,8 @@ def _rc_present(self, *, force_sync: bool = False) -> dict: # Returns the present-result dict produced by ``GPUCanvasContext.present()``. bitmap = self._bitmap_and_format[0] + if not bitmap.flags.contiguous: + bitmap = bitmap.copy() self._texture_helper.set_texture_data(bitmap) if not self._wgpu_context_is_configured: diff --git a/tests/test_context.py b/tests/test_context.py index 80899713..cd24c3c8 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -228,8 +228,7 @@ def test_bitmap_context(): # Draw! This is not *that* interesting, it just passes the bitmap around result = canvas.draw() - assert isinstance(result, memoryview) - result = np.asarray(result) + assert isinstance(result, np.ndarray) assert np.all(result == bitmap) # pssst ... it's actually the same data! From 0e540ba9f1d9b6b8ba4dc212d47cd40d530e8cab Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 27 Jan 2026 13:18:56 +0100 Subject: [PATCH 46/50] minor tweaks --- rendercanvas/jupyter.py | 3 +-- rendercanvas/pyodide.py | 3 ++- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/rendercanvas/jupyter.py b/rendercanvas/jupyter.py index 744aacc3..974c03c1 100644 --- a/rendercanvas/jupyter.py +++ b/rendercanvas/jupyter.py @@ -84,9 +84,8 @@ def _rc_force_paint(self): self._rfb_send_frame(array) def _rc_present_bitmap(self, *, data, format, **kwargs): - # Convert memoryview to ndarray (no copy) assert format == "rgba-u8" - self._last_image = np.frombuffer(data, np.uint8).reshape(data.shape) + self._last_image = data # already a numpy array def _rc_set_logical_size(self, width, height): self.css_width = f"{width}px" diff --git a/rendercanvas/pyodide.py b/rendercanvas/pyodide.py index 6962bd0d..1b3f2ed4 100644 --- a/rendercanvas/pyodide.py +++ b/rendercanvas/pyodide.py @@ -101,6 +101,7 @@ def __init__( self._canvas_element = canvas_element # We need a buffer to store pixel data, until we figure out how we can map a Python memoryview to a JS ArrayBuffer without making a copy. + # TODO: if its any easier for a numpy array, we could go that route! self._js_array = Uint8ClampedArray.new(0) # We use an offscreen canvas when the bitmap texture does not match the physical pixels. You should see it as a GPU texture. @@ -464,7 +465,7 @@ def _rc_force_paint(self): def _rc_present_bitmap(self, **kwargs): data = kwargs.get("data") - # Convert to memoryview. It probably already is. + # Convert to memoryview (from a numpy array) m = memoryview(data) h, w = m.shape[:2] From 03b6345bc196a62f63867a47417e30998e3d1b1a Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 27 Jan 2026 13:21:14 +0100 Subject: [PATCH 47/50] fix --- rendercanvas/jupyter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rendercanvas/jupyter.py b/rendercanvas/jupyter.py index 974c03c1..044852d5 100644 --- a/rendercanvas/jupyter.py +++ b/rendercanvas/jupyter.py @@ -85,7 +85,7 @@ def _rc_force_paint(self): def _rc_present_bitmap(self, *, data, format, **kwargs): assert format == "rgba-u8" - self._last_image = data # already a numpy array + self._last_image = np.asarray(data) def _rc_set_logical_size(self, width, height): self.css_width = f"{width}px" From 93926449fafe096a4814cf37efdc5b11b94f0d3c Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 28 Jan 2026 08:31:46 +0100 Subject: [PATCH 48/50] Apply suggestions from code review Co-authored-by: Jan --- rendercanvas/base.py | 2 +- rendercanvas/contexts/wgpucontext.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/rendercanvas/base.py b/rendercanvas/base.py index 362a2a92..aa40d388 100644 --- a/rendercanvas/base.py +++ b/rendercanvas/base.py @@ -165,7 +165,7 @@ def __init__( if isinstance(self, WrapperRenderCanvas): return - # The vsync is not-so-elegantly stored on the canvas, and picked up by wgou's canvas contex. + # The vsync is not-so-elegantly stored on the canvas, and picked up by wgpu's canvas contex. self._vsync = bool(vsync) # Handle custom present method diff --git a/rendercanvas/contexts/wgpucontext.py b/rendercanvas/contexts/wgpucontext.py index 5d596288..d346e030 100644 --- a/rendercanvas/contexts/wgpucontext.py +++ b/rendercanvas/contexts/wgpucontext.py @@ -361,7 +361,7 @@ def _clear_pending_download(self): def _get_awaitable_for_download(self, texture, present_params=None): # First clear any pending downloads. - # This covers cases when switching between ``force_draw()`` normal rendering. + # This covers cases when switching between ``force_draw()`` and normal rendering. self._clear_pending_download() # Create new action object and make sure that the buffer is the correct size From b6bb81ad92adff400bc227a1198580e732453d83 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 30 Jan 2026 11:18:23 +0100 Subject: [PATCH 49/50] add version checks for wgpu --- rendercanvas/contexts/wgpucontext.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/rendercanvas/contexts/wgpucontext.py b/rendercanvas/contexts/wgpucontext.py index d346e030..420c0d30 100644 --- a/rendercanvas/contexts/wgpucontext.py +++ b/rendercanvas/contexts/wgpucontext.py @@ -4,7 +4,7 @@ import numpy as np from .basecontext import BaseContext -from .._coreutils import log_exception +from .._coreutils import logger, log_exception __all__ = ["WgpuContext", "WgpuContextToBitmap", "WgpuContextToScreen"] @@ -208,6 +208,16 @@ def _get_capabilities(self): import wgpu + # Earlier versions wgpu may not be optimal, or may not even work. + if wgpu.version_info < (0, 27): + raise RuntimeError( + f"The version of wgpu {wgpu.__version__!r} is too old to support bitmap-present of the current version of rendercanvas. Please update wgpu-py." + ) + if wgpu.version_info < (1, 29): + logger.warning( + f"The version of wgpu is {wgpu.__version__!r}, you probably want to upgrade to at least 0.29 to benefit from performance upgrades for async-bitmap-present." + ) + # Store usage flags now that we have the wgpu namespace self._context_texture_usage = wgpu.TextureUsage.COPY_SRC self._context_buffer_usage = ( From 3d0510fb2f3f28b6b61a87e25e3f296944a52d5d Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 30 Jan 2026 14:11:08 +0100 Subject: [PATCH 50/50] Fix flicker on qt --- rendercanvas/qt.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/rendercanvas/qt.py b/rendercanvas/qt.py index 7264ccd1..17a58042 100644 --- a/rendercanvas/qt.py +++ b/rendercanvas/qt.py @@ -332,7 +332,7 @@ def _get_surface_ids(self): def paintEngine(self): # noqa: N802 - this is a Qt method # https://doc.qt.io/qt-5/qt.html#WidgetAttribute-enum WA_PaintOnScreen - if self._present_to_screen: + if self._present_to_screen or self._present_to_screen is None: return None else: return super().paintEngine() @@ -384,6 +384,8 @@ def _rc_get_present_info(self, present_methods): if the_method == "screen": surface_ids = self._get_surface_ids() if surface_ids: + # Now is a good time to set WA_PaintOnScreen. Note that it implies WA_NativeWindow. + self.setAttribute(WA_PaintOnScreen, True) return {"method": "screen", **surface_ids} elif "bitmap" in present_methods: return {