diff --git a/pyproject.toml b/pyproject.toml index 1162a47..9fce626 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/_scheduler.py b/rendercanvas/_scheduler.py index 5e0be96..6d13592 100644 --- a/rendercanvas/_scheduler.py +++ b/rendercanvas/_scheduler.py @@ -51,9 +51,10 @@ 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._async_draw_event = None + self._ready_for_present = None # Keep track of fps self._draw_stats = 0, time.perf_counter() @@ -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": @@ -150,45 +160,61 @@ 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 not do_draw: - # If we don't want to draw, move to the next iter + 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 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. However, this does *not* + # seem to be faster! Tested on MacOS M1 and Windows with NVidia + # on the cube example. + # + # 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() del canvas - continue + if self._ready_for_present: + await self._ready_for_present.wait() else: - # Otherwise, request a draw ... - canvas._rc_request_draw() + # If we have a non-drawing tick, at least process events. + canvas._process_events() 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() # 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._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 # Update stats count, last_time = self._draw_stats diff --git a/rendercanvas/base.py b/rendercanvas/base.py index 6daa736..aa40d38 100644 --- a/rendercanvas/base.py +++ b/rendercanvas/base.py @@ -21,6 +21,7 @@ from ._scheduler import Scheduler from ._coreutils import logger, log_exception + if TYPE_CHECKING: from typing import Callable, Literal, Optional @@ -160,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 wgpu's canvas contex. self._vsync = bool(vsync) # Handle custom present method @@ -173,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 @@ -320,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}." @@ -341,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} @@ -493,74 +495,155 @@ def force_draw(self) -> None: """ if self.__is_drawing: raise RuntimeError("Cannot force a draw while drawing.") - self._rc_force_draw() + if self._present_to_screen: + self._rc_force_paint() # -> _time_to_paint() -> _draw_and_present() + else: + self._draw_and_present(force_sync=True) + self._rc_force_paint() # May or may not work + + def _time_to_draw(self): + """It's time to draw! + + To get here, ``_rc_request_draw()`` was probably called first: + + _rc_request_draw() -> ... -> _time_to_draw() + + The drawing happens asynchronously, and follows a different path for the different present methods. + + For the 'screen' method: + + _rc_request_paint() -> ... -> _time_to_paint() -> _draw_and_present() -> _finish_present() + + For the 'bitmap' method: - def _draw_frame_and_present(self): - """Draw the frame and present the result. + _draw_and_present() -> ... -> finish_present() -> _rc_request_paint() + """ + + # 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() + else: + self._draw_and_present(force_sync=False) + + def _time_to_paint(self): + """Callback for _rc_request_paint. - Errors are logged to the "rendercanvas" logger. Should be called by the - subclass at its draw event. + 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 self._present_to_screen: + self._draw_and_present(force_sync=True) + + def _draw_and_present(self, *, force_sync: bool): + """Draw the frame and init the presentation.""" - # 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: - # 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. + # 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.) - # Cannot draw to a closed canvas. - if self._rc_get_closed() or self._draw_frame is None: + 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 - # Note: could check whether the known physical size is > 0. - # But we also consider it the responsiblity 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"}) - # 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"): - # Note: we use canvas._canvas_context, so that if the draw_frame is a stub we also dont trigger creating a context. + + # 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) - context = self._canvas_context - if context: + + if force_sync: + result = context._rc_present(force_sync=True) + assert result["method"] != "async" + self._finish_present(result) + else: 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") + if result["method"] == "async": + result["awaitable"].then(self._finish_present) else: - # Pass the result to the literal present method - func = getattr(self, f"_rc_present_{method}") - func(**result) + self._finish_present(result) finally: self.__is_drawing = False + def _finish_present(self, result): + """Wrap up the presentation process.""" + + 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": + 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) + # Now the backend must repaint to show the new image + self._rc_request_paint() + + # Notify the scheduler + if self.__scheduler is not None: + frame_time = self.__scheduler.on_draw_done() + + # 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"]) + + 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]: @@ -697,25 +780,46 @@ def _rc_get_present_info(self, present_methods: list[str]) -> dict | None: return None def _rc_request_draw(self): - """Request the GUI layer to perform a draw. + """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 ``_draw_frame_and_present()``. 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 ``_draw_frame_and_present()``. + The backend should, if possible, invoke its native paint event right now (synchronously). + The default implementation just calls ``_time_to_paint()``. """ - self._draw_frame_and_present() + 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/_fullscreen.py b/rendercanvas/contexts/_fullscreen.py index 8429b91..948e5d0 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 d880d9c..c406e6b 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"" @@ -88,12 +90,17 @@ 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) -> 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: @@ -103,13 +110,17 @@ def _rc_present(self): * 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 return {"method": "skip"} + 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/bitmapcontext.py b/rendercanvas/contexts/bitmapcontext.py index 2fe667e..61c3694 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 - conveted 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): @@ -81,22 +84,24 @@ 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"} 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, @@ -130,7 +135,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"} @@ -138,6 +143,8 @@ def _rc_present(self): # 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/rendercanvas/contexts/wgpucontext.py b/rendercanvas/contexts/wgpucontext.py index 7d490d4..420c0d3 100644 --- a/rendercanvas/contexts/wgpucontext.py +++ b/rendercanvas/contexts/wgpucontext.py @@ -1,6 +1,10 @@ +import time from typing import Sequence +import numpy as np + from .basecontext import BaseContext +from .._coreutils import logger, log_exception __all__ = ["WgpuContext", "WgpuContextToBitmap", "WgpuContextToScreen"] @@ -99,7 +103,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 @@ -126,7 +129,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) -> dict: """Hook for the canvas to present the rendered result. Present what has been drawn to the current texture, by compositing it to the @@ -161,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) -> dict: self._wgpu_context.present() return {"method": "screen"} @@ -186,14 +189,41 @@ 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. + # We have a single texture (not a ring of textures), because copying the + # contents to a download-buffer is near-instant. self._texture = None + # 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.""" 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 = ( + wgpu.BufferUsage.COPY_DST | wgpu.BufferUsage.MAP_READ + ) + capabilities = {} # Query format capabilities from the info provided by the canvas @@ -260,8 +290,12 @@ def _configure(self, config: dict): f"Configure: unsupported alpha-mode: {alpha_mode} not in {cap_alpha_modes}" ) + # (re)create downloader + self._downloader = ImageDownloader(config["device"], self._context_buffer_usage) + def _unconfigure(self) -> None: self._drop_texture() + self._downloader = None def _get_current_texture(self): # When the texture is active right now, we could either: @@ -270,39 +304,205 @@ 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: - import wgpu - - 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"] | wgpu.TextureUsage.COPY_SRC, + usage=self._config["usage"] | self._context_texture_usage, ) return self._texture - def _rc_present(self) -> None: + def _rc_present(self, *, force_sync: bool = False) -> dict: if not self._texture: return {"method": "skip"} + 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} - bitmap = self._get_bitmap() + def _rc_close(self): self._drop_texture() - return {"method": "bitmap", "format": "rgba-u8", "data": bitmap} - def _get_bitmap(self): - texture = self._texture - device = texture._device +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 + self._buffer = None + self._awaitable = None + 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, present_params=None): + # First clear any pending downloads. + # 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 + action = AsyncImageDownloadAction(texture, present_params) + 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. + awaitable = self._buffer.map_async("READ_NOSYNC", 0, nbytes) + + self._action = action + self._awaitable = awaitable + + 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, present_params): + # Start a fresh download + self._get_awaitable_for_download(texture) + + # With a fresh action + self._action.cancel() + action = AsyncImageDownloadAction(texture, present_params) + action.set_buffer(self._buffer) + + # Async-wait, then resolve + self._awaitable.sync_wait() + result = action.resolve() + assert result is not None + 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, 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 + 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: + 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 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: @@ -316,38 +516,114 @@ 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, - ) - - # Derive struct dtype from wgpu texture 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._dtype = dtype + self._nchannels = nchannels + self._padded_stride = padded_stride + self._texture_size = size + + # For ImageDownloader + self.stride = padded_stride + self.nbytes = padded_stride * size[1] + + def _get_bitmap(self, buffer): + dtype = self._dtype + nchannels = self._nchannels + 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. + + # Get array + if buffer.map_state == "pending": + raise RuntimeError("Buffer state is 'pending' in get_bitmap()") + mapped_data = buffer.read_mapped(copy=False) + + # 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() - # Represent as memory object to avoid numpy dependency - # Equivalent: np.frombuffer(data, np.uint8).reshape(size[1], size[0], nchannels) + 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. - return data.cast(memoryview_type, (size[1], size[0], nchannels)) + # 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], :] - def _rc_close(self): - self._drop_texture() + 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: + raise RuntimeError(f"Unknown present submethod {submethod!r}") + + return data diff --git a/rendercanvas/glfw.py b/rendercanvas/glfw.py index 642641b..24d0ff9 100644 --- a/rendercanvas/glfw.py +++ b/rendercanvas/glfw.py @@ -252,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_draw() + self._set_visible(not iconified) def _determine_size(self): if self._window is None: @@ -320,12 +319,17 @@ def _rc_get_present_info(self, present_methods): return None # raises error def _rc_request_draw(self): - if not self._is_minimized: - loop = self._rc_canvas_group.get_loop() - loop.call_soon(self._draw_frame_and_present) + self._time_to_draw() - def _rc_force_draw(self): - self._draw_frame_and_present() + def _rc_request_paint(self): + loop = self._rc_canvas_group.get_loop() + loop.call_soon(self._paint) + + def _rc_force_paint(self): + self._paint() + + def _paint(self): + self._time_to_paint() def _rc_present_bitmap(self, **kwargs): raise NotImplementedError() @@ -399,7 +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: - self._draw_frame_and_present() + 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 0d8357b..044852d 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 _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._draw_frame_and_present() + self._time_to_draw() return self._last_image # %% Methods to implement RenderCanvas @@ -65,22 +65,27 @@ def _rc_get_present_info(self, present_methods): 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) 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 = np.asarray(data) def _rc_set_logical_size(self, width, height): self.css_width = f"{width}px" diff --git a/rendercanvas/offscreen.py b/rendercanvas/offscreen.py index c59bccf..73d9203 100644 --- a/rendercanvas/offscreen.py +++ b/rendercanvas/offscreen.py @@ -62,12 +62,16 @@ def _rc_get_present_info(self, present_methods): return None # raises error 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): - self._draw_frame_and_present() + def _rc_force_paint(self): + self._time_to_paint() def _rc_present_bitmap(self, *, data, format, **kwargs): self._last_image = data @@ -117,11 +121,9 @@ 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._draw_frame_and_present() + self.force_draw() return self._last_image diff --git a/rendercanvas/pyodide.py b/rendercanvas/pyodide.py index b5ee10a..1b3f2ed 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. @@ -449,20 +450,22 @@ def _rc_get_present_info(self, present_methods): return None # raises error def _rc_request_draw(self): - window.requestAnimationFrame( - create_proxy(lambda _: self._draw_frame_and_present()) - ) + # 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._draw_frame_and_present() + self._time_to_paint() 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] diff --git a/rendercanvas/qt.py b/rendercanvas/qt.py index e17cfeb..17a5804 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 @@ -36,6 +37,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 +52,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 @@ -278,9 +281,11 @@ class QRenderWidget(BaseRenderCanvas, QtWidgets.QWidget): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + # Determine present method + self._last_image = None self._last_winid = None - self._present_to_screen = None self._is_closed = False + self._pending_present_params = None self.setAutoFillBackground(False) self.setAttribute(WA_DeleteOnClose, True) @@ -327,18 +332,31 @@ 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() def paintEvent(self, event): # noqa: N802 - this is a Qt method - self._draw_frame_and_present() - - 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() + self._time_to_paint() + if self._last_image is not None: + image = self._last_image[0] + + # 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. + # # Eventually this will call _request_draw(). + # self.request_draw() # %% Methods to implement RenderCanvas @@ -366,10 +384,10 @@ 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 + # 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: - self._present_to_screen = False return { "method": "bitmap", "formats": list(BITMAP_FORMAT_MAP.keys()), @@ -377,7 +395,7 @@ def _rc_get_present_info(self, present_methods): else: return None elif the_method == "bitmap": - self._present_to_screen = False + self._pending_present_params = {"submethod": "contiguous-array"} return { "method": "bitmap", "formats": list(BITMAP_FORMAT_MAP.keys()), @@ -386,6 +404,12 @@ 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): # Ask Qt to do a paint event QtWidgets.QWidget.update(self) @@ -394,12 +418,15 @@ def _rc_request_draw(self): if not isinstance(loop, QtLoop): loop.call_soon(self._rc_gui_poll) - 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. - # 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.). + 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 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: @@ -431,21 +458,17 @@ 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) + # 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] - 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(thedata, width, height, bytes_per_line, qtformat), + data, + ) def _rc_set_logical_size(self, width, height): width, height = int(width), int(height) @@ -664,6 +687,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() == 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 302c958..31d7978 100644 --- a/rendercanvas/stub.py +++ b/rendercanvas/stub.py @@ -45,7 +45,7 @@ def _rc_call_soon_threadsafe(self, callback): class StubCanvasGroup(BaseCanvasGroup): """ - The ``CanvasGroup`` representss a group of canvas objects from the same class, that share a loop. + The ``CanvasGroup`` represents a group of canvas objects from the same class, that share a loop. The initial/default loop is passed when the ``CanvasGroup`` is instantiated. @@ -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._draw_frame_and_present()`` 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 @@ -92,10 +89,13 @@ def _rc_get_present_info(self, present_methods): return None def _rc_request_draw(self): + self._time_to_draw() + + def _rc_request_paint(self): pass - def _rc_force_draw(self): - self._draw_frame_and_present() + 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 97c5b9a..dc3da45 100644 --- a/rendercanvas/wx.py +++ b/rendercanvas/wx.py @@ -211,7 +211,7 @@ class WxRenderWidget(BaseRenderCanvas, wx.Window): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self._present_to_screen = None + self._last_image = None self._is_closed = False self._pointer_inside = None self._is_pointer_inside_according_to_wx = False @@ -244,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._draw_frame_and_present() + 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"): @@ -296,10 +299,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"], @@ -307,7 +308,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"], @@ -316,6 +316,9 @@ def _rc_get_present_info(self, present_methods): return None # raises error def _rc_request_draw(self): + self._time_to_draw() + + def _rc_request_paint(self): if self._draw_lock: return try: @@ -323,18 +326,18 @@ def _rc_request_draw(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() + if sys.platform == "darwin": + wx.Yield() 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) diff --git a/tests/test_base.py b/tests/test_base.py index 46d58a0..6625542 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 @@ -43,10 +50,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.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 +65,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.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 +106,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 _time_to_draw() in event loop. canvas = MyOffscreenCanvas() - canvas._draw_frame_and_present() + 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 34d38af..cd24c3c 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"] @@ -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! @@ -251,8 +250,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 @@ -266,8 +265,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_gui_glfw.py b/tests/test_gui_glfw.py index c256806..2b82e8f 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) diff --git a/tests/test_loop.py b/tests/test_loop.py index 93e0eb0..ac21546 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_paint(self): loop = self._rc_canvas_group.get_loop() - loop.call_soon(self._draw_frame_and_present) + loop.call_soon(self._time_to_paint) # %%%%% deleting loops diff --git a/tests/test_meta.py b/tests/test_meta.py index 468bb83..e1f5584 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 diff --git a/tests/test_scheduling.py b/tests/test_scheduling.py index f2688dd..762044c 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,6 +36,8 @@ def __init__(self, *args, **kwargs): self.draw_count = 0 self.events_count = 0 self._gui_draw_requested = False + self._present_to_screen = False + self._canvas_context = StubContext() def _rc_close(self): self._closed = True @@ -42,25 +49,16 @@ def _process_events(self): self.events_count += 1 return super()._process_events() - def _draw_frame_and_present(self): - super()._draw_frame_and_present() + def _draw_and_present(self, *, force_sync): + super()._draw_and_present(force_sync=force_sync) self.draw_count += 1 - def _rc_request_draw(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() - 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 cd8c4be..fd14a81 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_draw(self): - loop = self._rc_canvas_group.get_loop() - loop.call_soon(self._draw_frame_and_present) - def get_sniffio_name(): try: