From 285e7ae73dd3dfdaf844e9e1f9d97f2881d7e43b Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 24 Mar 2022 14:03:53 +0100 Subject: [PATCH] Apply rate limits for events (#247) * Add support for rate limiting events * rate-limit glfw events * rate-limit qt events * comments, docstrings, and move code around * Improve docs --- wgpu/gui/base.py | 114 ++++++++++++++++++++++++++++++++++---------- wgpu/gui/glfw.py | 33 ++++++------- wgpu/gui/jupyter.py | 2 + wgpu/gui/qt.py | 32 ++++++------- 4 files changed, 119 insertions(+), 62 deletions(-) diff --git a/wgpu/gui/base.py b/wgpu/gui/base.py index 8f24c108..b5c1e997 100644 --- a/wgpu/gui/base.py +++ b/wgpu/gui/base.py @@ -2,12 +2,44 @@ import sys import time import logging +from contextlib import contextmanager import ctypes.util from collections import defaultdict logger = logging.getLogger("wgpu") +err_hashes = {} + + +@contextmanager +def log_exception(kind): + """Context manager to log any exceptions, but only log a one-liner + for subsequent occurances of the same error to avoid spamming by + repeating errors in e.g. a draw function or event callback. + """ + try: + yield + except Exception as err: + # Store exc info for postmortem debugging + exc_info = list(sys.exc_info()) + exc_info[2] = exc_info[2].tb_next # skip *this* function + sys.last_type, sys.last_value, sys.last_traceback = exc_info + # Show traceback, or a one-line summary + msg = str(err) + msgh = hash(msg) + if msgh not in err_hashes: + # Provide the exception, so the default logger prints a stacktrace. + # IDE's can get the exception from the root logger for PM debugging. + err_hashes[msgh] = 1 + logger.error(kind, exc_info=err) + else: + # We've seen this message before, return a one-liner instead. + err_hashes[msgh] = count = err_hashes[msgh] + 1 + msg = kind + ": " + msg.split("\n")[0].strip() + msg = msg if len(msg) <= 70 else msg[:69] + "…" + logger.error(msg + f" ({count})") + class WgpuCanvasInterface: """This is the interface that a canvas object must implement in order @@ -109,15 +141,11 @@ def _draw_frame_and_present(self): # Perform the user-defined drawing code. When this errors, # we should report the error and then continue, otherwise we crash. # Returns the result of the context's present() call or None. - try: + with log_exception("Draw error"): self.draw_frame() - except Exception as err: - self._log_exception("Draw error", err) - try: + with log_exception("Present error"): if self._canvas_context: return self._canvas_context.present() - except Exception as err: - self._log_exception("Present error", err) def _get_draw_wait_time(self): """Get time (in seconds) to wait until the next draw in order to honour max_fps.""" @@ -125,25 +153,6 @@ def _get_draw_wait_time(self): target_time = self._last_draw_time + 1.0 / self._max_fps return max(0, target_time - now) - def _log_exception(self, kind, err): - """Log the given exception instance, but only log a one-liner for - subsequent occurances of the same error to avoid spamming (which - can happen easily with errors in the drawing code). - """ - msg = str(err) - msgh = hash(msg) - if msgh not in self._err_hashes: - # Provide the exception, so the default logger prints a stacktrace. - # IDE's can get the exception from the root logger for PM debugging. - self._err_hashes[msgh] = 1 - logger.error(kind, exc_info=err) - else: - # We've seen this message before, return a one-liner instead. - self._err_hashes[msgh] = count = self._err_hashes[msgh] + 1 - msg = kind + ": " + msg.split("\n")[0].strip() - msg = msg if len(msg) <= 70 else msg[:69] + "…" - logger.error(msg + f" ({count})") - # Methods that must be overloaded def get_pixel_ratio(self): @@ -184,8 +193,45 @@ class WgpuAutoGui: def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + self._last_event_time = 0 + self._pending_events = {} self._event_handlers = defaultdict(set) + def _get_event_wait_time(self): + """Calculate the time to wait for the next event dispatching + (for rate-limited events).""" + rate = 75 # events per second + now = time.perf_counter() + target_time = self._last_event_time + 1.0 / rate + return max(0, target_time - now) + + def _handle_event_rate_limited(self, ev, call_later_func, match_keys, accum_keys): + """Alternative `to handle_event()` for events that must be rate-limted. + If any of the `match_keys` keys of the new event differ from the currently + pending event, the old event is dispatched now. The `accum_keys` keys of + the current and new event are added together (e.g. to accumulate wheel delta). + + This method is called in the following cases: + * When the timer runs out. + * When a non-rate-limited event is dispatched. + * When a rate-limited event of the same type is scheduled + that has different match_keys (e.g. modifiers changes). + """ + event_type = ev["event_type"] + # We may need to emit the old event. Otherwise, we need to update the new one. + old = self._pending_events.get(event_type, None) + if old: + if any(ev[key] != old[key] for key in match_keys): + self._dispatch_event(old) + else: + for key in accum_keys: + ev[key] = old[key] + ev[key] + # Make sure that we have scheduled a moment to handle events + if not self._pending_events: + call_later_func(self._get_event_wait_time(), self._dispatch_pending_events) + # Store the event object + self._pending_events[event_type] = ev + def handle_event(self, event): """Handle an incoming event. @@ -194,9 +240,25 @@ def handle_event(self, event): is a dict with at least the key event_type. For details, see https://jupyter-rfb.readthedocs.io/en/latest/events.html """ + # On any not-rate-limited event, we dispatch any pending events. + # This is to make sure that the original order of events is preserved. + self._dispatch_pending_events() + self._dispatch_event(event) + + def _dispatch_pending_events(self): + """Handle any pending rate-limited events.""" + events = self._pending_events.values() + self._last_event_time = time.perf_counter() + self._pending_events = {} + for ev in events: + self._dispatch_event(ev) + + def _dispatch_event(self, event): + """Dispatch event to the event handlers.""" event_type = event.get("event_type") for callback in self._event_handlers[event_type]: - callback(event) + with log_exception(f"Error during handling {event['event_type']} event"): + callback(event) def add_event_handler(self, *args): """Register an event handler. diff --git a/wgpu/gui/glfw.py b/wgpu/gui/glfw.py index fd88ed01..887a3da2 100644 --- a/wgpu/gui/glfw.py +++ b/wgpu/gui/glfw.py @@ -13,7 +13,6 @@ import time import weakref import asyncio -import traceback import glfw @@ -200,7 +199,7 @@ def _on_size_change(self, *args): def _on_close(self, *args): all_glfw_canvases.discard(self) glfw.hide_window(self._window) - self._emit_event({"event_type": "close"}) + self.handle_event({"event_type": "close"}) def _on_window_dirty(self, *args): self._request_draw() @@ -230,7 +229,7 @@ def _determine_size(self): "height": self._logical_size[1], "pixel_ratio": self._pixel_ratio, } - self._emit_event(ev) + self.handle_event(ev) def _set_logical_size(self, new_logical_size): # There is unclarity about the window size in "screen pixels". @@ -316,16 +315,6 @@ def close(self): def is_closed(self): return glfw.window_should_close(self._window) - def _emit_event(self, event): - try: - self.handle_event(event) - except Exception: - # Print exception and store exc info for postmortem debugging - exc_info = list(sys.exc_info()) - exc_info[2] = exc_info[2].tb_next # skip *this* function - sys.last_type, sys.last_value, sys.last_traceback = exc_info - traceback.print_exception(*exc_info) - # User events def _on_mouse_button(self, window, but, action, mods): @@ -362,8 +351,11 @@ def _on_mouse_button(self, window, but, action, mods): "ntouches": 0, # glfw dows not have touch support "touches": {}, } - self._emit_event(ev) + # Emit the current event + self.handle_event(ev) + + # Maybe emit a double-click event self._follow_double_click(action, button) def _follow_double_click(self, action, button): @@ -413,7 +405,7 @@ def _follow_double_click(self, action, button): "ntouches": 0, # glfw dows not have touch support "touches": {}, } - self._emit_event(ev) + self.handle_event(ev) def _on_cursor_pos(self, window, x, y): # Store pointer position in logical coordinates @@ -432,7 +424,10 @@ def _on_cursor_pos(self, window, x, y): "ntouches": 0, # glfw dows not have touch support "touches": {}, } - self._emit_event(ev) + + match_keys = {"buttons", "modifiers", "ntouches"} + accum_keys = {} + self._handle_event_rate_limited(ev, call_later, match_keys, accum_keys) def _on_scroll(self, window, dx, dy): # wheel is 1 or -1 in glfw, in jupyter_rfb this is ~100 @@ -444,7 +439,9 @@ def _on_scroll(self, window, dx, dy): "y": self._pointer_pos[1], "modifiers": list(self._key_modifiers), } - self._emit_event(ev) + match_keys = {"modifiers"} + accum_keys = {"dx", "dy"} + self._handle_event_rate_limited(ev, call_later, match_keys, accum_keys) def _on_key(self, window, key, scancode, action, mods): @@ -481,7 +478,7 @@ def _on_key(self, window, key, scancode, action, mods): "key": keyname, "modifiers": list(self._key_modifiers), } - self._emit_event(ev) + self.handle_event(ev) # Make available under a name that is the same for all gui backends diff --git a/wgpu/gui/jupyter.py b/wgpu/gui/jupyter.py index 273a7d17..ad449f5c 100644 --- a/wgpu/gui/jupyter.py +++ b/wgpu/gui/jupyter.py @@ -48,6 +48,8 @@ def handle_event(self, event): self._pixel_ratio = event["pixel_ratio"] self._logical_size = event["width"], event["height"] + # No need to rate-limit the pointer_move and wheel events; + # they're already rate limited by jupyter_rfb in the client. super().handle_event(event) def get_frame(self): diff --git a/wgpu/gui/qt.py b/wgpu/gui/qt.py index 7d2e9d24..12a711e0 100644 --- a/wgpu/gui/qt.py +++ b/wgpu/gui/qt.py @@ -6,10 +6,10 @@ import ctypes import importlib import sys -import traceback from .base import WgpuCanvasBase, WgpuAutoGui + # Select GUI toolkit for libname in ("PySide6", "PyQt6", "PySide2", "PyQt5"): if libname in sys.modules: @@ -267,18 +267,6 @@ def get_context(self, *args, **kwargs): def request_draw(self, *args, **kwargs): return self._subwidget.request_draw(*args, **kwargs) - # Auto event API - - def _emit_event(self, event): - try: - self.handle_event(event) - except Exception: - # Print exception and store exc info for postmortem debugging - exc_info = list(sys.exc_info()) - exc_info[2] = exc_info[2].tb_next # skip *this* function - sys.last_type, sys.last_value, sys.last_traceback = exc_info - traceback.print_exception(*exc_info) - # User events to jupyter_rfb events def _key_event(self, event_type, event): @@ -293,7 +281,7 @@ def _key_event(self, event_type, event): "key": KEY_MAP.get(event.key(), event.text()), "modifiers": modifiers, } - self._emit_event(ev) + self.handle_event(ev) def keyPressEvent(self, event): # noqa: N802 self._key_event("key_down", event) @@ -331,7 +319,13 @@ def _mouse_event(self, event_type, event, touches=True): "touches": {}, # TODO } ) - self._emit_event(ev) + + if event_type == "pointer_move": + match_keys = {"buttons", "modifiers", "ntouches"} + accum_keys = {} + self._handle_event_rate_limited(ev, call_later, match_keys, accum_keys) + else: + self.handle_event(ev) def mousePressEvent(self, event): # noqa: N802 self._mouse_event("pointer_down", event) @@ -362,7 +356,9 @@ def wheelEvent(self, event): # noqa: N802 "y": event.position().y(), "modifiers": modifiers, } - self._emit_event(ev) + match_keys = {"modifiers"} + accum_keys = {"dx", "dy"} + self._handle_event_rate_limited(ev, call_later, match_keys, accum_keys) def resizeEvent(self, event): # noqa: N802 ev = { @@ -371,10 +367,10 @@ def resizeEvent(self, event): # noqa: N802 "height": float(event.size().height()), "pixel_ratio": self.get_pixel_ratio(), } - self._emit_event(ev) + self.handle_event(ev) def closeEvent(self, event): # noqa: N802 - self._emit_event({"event_type": "close"}) + self.handle_event({"event_type": "close"}) # Make available under a name that is the same for all gui backends