diff --git a/rendercanvas/_scheduler.py b/rendercanvas/_scheduler.py index 5e0be96..6b2b367 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 bb04f00..a1a779a 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 d880d9c..650753f 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/bitmapcontext.py b/rendercanvas/contexts/bitmapcontext.py index 3a5a5c0..879d1d7 100644 --- a/rendercanvas/contexts/bitmapcontext.py +++ b/rendercanvas/contexts/bitmapcontext.py @@ -36,7 +36,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 8d9f96a..ebfef46 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 @@ -99,7 +100,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 +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" @@ -186,14 +188,33 @@ 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 + # 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. + self._downloaders = [None] # Put as many None's as you want buffers + 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._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 +281,15 @@ 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 + ] + 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: @@ -271,8 +299,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) @@ -283,7 +309,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._context_texture_usage, ) return self._texture @@ -292,17 +318,74 @@ 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! + + # # 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} + + # downloader._awaitable + + 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._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) + return self._awaitable + + 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,21 +399,6 @@ 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" if "float" in format: memoryview_type = "e" if "16" in format else "f" @@ -344,10 +412,107 @@ def _get_bitmap(self): 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) + 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._nchannels = nchannels + self._plain_stride = plain_stride + 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 + ) - return data.cast(memoryview_type, (size[1], size[0], nchannels)) + def _copy_texture(self, texture): + source = { + "texture": texture, + "mip_level": 0, + "origin": (0, 0, 0), + } - def _rc_close(self): - self._drop_texture() + 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 + + 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 self._buffer.map_state == "pending": + self._awaitable.sync_wait() + mapped_data = self._buffer.read_mapped(copy=False) + + # 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]): + 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) + + # 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) + + # 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) + + return data diff --git a/rendercanvas/glfw.py b/rendercanvas/glfw.py index 0153787..1c15aea 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 44eda92..2194e63 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 77748b5..5b59af0 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 7c2723b..27e0467 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 5d92ab6..d07fc5d 100644 --- a/rendercanvas/qt.py +++ b/rendercanvas/qt.py @@ -283,6 +283,8 @@ def __init__(self, *args, present_method=None, **kwargs): super().__init__(*args, **kwargs) # Determine present method + self._last_image = None + self._image_count = 0 self._last_winid = None self._surface_ids = None if not present_method: @@ -357,7 +359,20 @@ 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() + self._on_animation_frame() + if self._last_image is not None: + image = self._last_image + + # 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. @@ -395,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) @@ -444,18 +459,8 @@ 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) + 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 3ccf678..d9aa9b7 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,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 0609c91..14e6b8a 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 74da38a..2ed6ade 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 c05d5e9..94dbb0a 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 f2688dd..bd1c240 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 cd8c4be..de2c139 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():