Skip to content

Commit

Permalink
self review, and title logic
Browse files Browse the repository at this point in the history
  • Loading branch information
almarklein committed Oct 28, 2024
1 parent edf5706 commit b1096ef
Show file tree
Hide file tree
Showing 9 changed files with 155 additions and 99 deletions.
55 changes: 55 additions & 0 deletions examples/gui_demo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""
An example that uses events to trigger some canvas functionality.
A nice demo, and very convenient to test the different backends.
* Can be closed with Escape or by pressing the window close button.
* In both cases, it should print "Close detected" exactly once.
* Hit space to spend 2 seconds doing direct draws.
"""

import time

from wgpu.gui.auto import WgpuCanvas, loop

from cube import setup_drawing_sync


canvas = WgpuCanvas(
size=(640, 480),
title="Canvas events on $backend - $fps fps",
max_fps=10,
update_mode="continuous",
present_method="",
)


draw_frame = setup_drawing_sync(canvas)
canvas.request_draw(lambda: (draw_frame(), canvas.request_draw()))


@canvas.add_event_handler("*")
def process_event(event):
if event["event_type"] not in ["pointer_move", "before_draw", "animate"]:
print(event)

if event["event_type"] == "key_down":
if event["key"] == "Escape":
canvas.close()
elif event["key"] == " ":
etime = time.time() + 2
i = 0
while time.time() < etime:
i += 1
canvas.force_draw()
print(f"force-drawed {i} frames in 2s.")
elif event["event_type"] == "close":
# Should see this exactly once, either when pressing escape, or
# when pressing the window close button.
print("Close detected!")
assert canvas.is_closed()


if __name__ == "__main__":
loop.run()
28 changes: 2 additions & 26 deletions examples/gui_events.py
Original file line number Diff line number Diff line change
@@ -1,50 +1,26 @@
"""
A simple example to demonstrate events.
Also serves as a test-app for the canvas backends.
"""

import time

from wgpu.gui.auto import WgpuCanvas, loop

from cube import setup_drawing_sync


canvas = WgpuCanvas(
size=(640, 480),
title="wgpu events",
max_fps=10,
update_mode="continuous",
present_method="",
size=(640, 480), title="Canvas events on $backend", update_mode="continuous"
)


draw_frame = setup_drawing_sync(canvas)
canvas.request_draw(lambda: (draw_frame(), canvas.request_draw()))
canvas.request_draw(draw_frame)


@canvas.add_event_handler("*")
def process_event(event):
if event["event_type"] not in ["pointer_move", "before_draw", "animate"]:
print(event)

if event["event_type"] == "key_down":
if event["key"] == "Escape":
canvas.close()
elif event["key"] == " ":
etime = time.time() + 2
i = 0
while time.time() < etime:
i += 1
canvas.force_draw()
print(f"force-drawed {i} frames in 2s.")
elif event["event_type"] == "close":
# Should see this exactly once, either when pressing escape, or
# when pressing the window close button.
print("Close detected!")
assert canvas.is_closed()


if __name__ == "__main__":
loop.run()
78 changes: 32 additions & 46 deletions wgpu/gui/_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from ..enums import Enum

# Note: technically, we could have a global loop proxy object that defers to any of the other loops.
# That would e.g. allow using glfw with qt together. Probably to too weird use-case for the added complexity.
# That would e.g. allow using glfw with qt together. Probably a too weird use-case for the added complexity.


class WgpuTimer:
Expand Down Expand Up @@ -128,6 +128,14 @@ class WgpuLoop:
def __init__(self):
self._schedulers = set()
self._stop_when_no_canvases = False

# The loop object runs a lightweight timer for a few reasons:
# * Support running the loop without windows (e.g. to keep animations going).
# * Detect closed windows. Relying on the backend alone is tricky, since the
# loop usually stops when the last window is closed, so the close event may
# not be fired.
# * Keep the GUI going even when the canvas loop is on pause e.g. because its
# minimized (applies to backends that implement _wgpu_gui_poll).
self._gui_timer = self._TimerClass(self, self._tick, one_shot=False)

def _register_scheduler(self, scheduler):
Expand Down Expand Up @@ -237,20 +245,6 @@ def _wgpu_gui_poll(self):
pass


class AnimationScheduler:
"""
Some ideas:
* canvas.add_event_handler("animate", callback)
* canvas.animate.add_handler(1/30, callback)
"""

# def iter(self):
# # Something like this?
# for scheduler in all_schedulers:
# scheduler._event_emitter.submit_and_dispatch(event)


class UpdateMode(Enum):
"""The different modes to schedule draws for the canvas."""

Expand Down Expand Up @@ -299,8 +293,8 @@ class Scheduler:
#
# On desktop canvases the draw usually occurs very soon after it is
# requested, but on remote frame buffers, it may take a bit longer. To make
# sure the rendered image reflects the latest state, events are also
# processed right before doing the draw.
# sure the rendered image reflects the latest state, these backends may
# issue an extra call to _process_events() right before doing the draw.
#
# When the window is minimized, the draw will not occur until the window is
# shown again. For the canvas to detect minimized-state, it will need to
Expand All @@ -314,18 +308,13 @@ class Scheduler:
# don't affect the scheduling loop; they are just extra draws.

def __init__(self, canvas, loop, *, mode="ondemand", min_fps=1, max_fps=30):
# Objects related to the canvas.
# We don't keep a ref to the canvas to help gc. This scheduler object can be
# referenced via a callback in an event loop, but it won't prevent the canvas
# from being deleted!
self._canvas_ref = weakref.ref(canvas)
self._events = canvas._events
# ... = canvas.get_context() -> No, context creation should be lazy!

# We need to call_later and process gui events. The loop object abstracts these.
assert loop is not None
loop._register_scheduler(self)

# Scheduling variables
if mode not in UpdateMode:
raise ValueError(
Expand All @@ -335,20 +324,21 @@ def __init__(self, canvas, loop, *, mode="ondemand", min_fps=1, max_fps=30):
self._min_fps = float(min_fps)
self._max_fps = float(max_fps)
self._draw_requested = True # Start with a draw in ondemand mode

# Stats
self._last_draw_time = 0

# Keep track of fps
self._draw_stats = 0, time.perf_counter()

# Variables for animation
self._animation_time = 0
self._animation_step = 1 / 20
assert loop is not None

# Initialise the scheduling loop. Note that the gui may do a first draw
# earlier, starting the loop, and that's fine.
# Initialise the timer that runs our scheduling loop.
# Note that the gui may do a first draw earlier, starting the loop, and that's fine.
self._last_tick_time = -0.1
self._timer = loop.call_later(0.1, self._tick)

# Register this scheduler/canvas at the loop object
loop._register_scheduler(self)

def _get_canvas(self):
canvas = self._canvas_ref()
if canvas is None or canvas.is_closed():
Expand Down Expand Up @@ -415,7 +405,7 @@ def _tick(self):
self._min_fps > 0
and time.perf_counter() - self._last_draw_time > 1 / self._min_fps
):
canvas._request_draw() # time to do a draw
canvas._request_draw()
else:
self._schedule_next_tick()

Expand All @@ -429,26 +419,22 @@ def _tick(self):
def on_draw(self):
"""Called from canvas._draw_frame_and_present()."""

# It could be that the canvas is closed now. When that happens,
# we stop here and do not schedule a new iter.
if (canvas := self._get_canvas()) is None:
return
# Bookkeeping
self._last_draw_time = time.perf_counter()
self._draw_requested = False

# Keep ticking
self._schedule_next_tick()

# Update stats
count, last_time = self._draw_stats
count += 1
if time.perf_counter() - last_time > 1.0:
fps = count / (time.perf_counter() - last_time)
self._draw_stats = 0, time.perf_counter()
else:
self._draw_stats = count + 1, last_time
fps = None
self._draw_stats = count, last_time

# Stats (uncomment to see fps)
count, last_time = self._draw_stats
fps = count / (time.perf_counter() - last_time)
canvas.set_title(f"wgpu {fps:0.1f} fps")

# Bookkeeping
self._last_draw_time = time.perf_counter()
self._draw_requested = False

# Keep ticking
self._schedule_next_tick()
# Return fps or None. Will change with better stats at some point
return fps
30 changes: 23 additions & 7 deletions wgpu/gui/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,13 @@ def __init__(
self._vsync = bool(vsync)
present_method # noqa - We just catch the arg here in case a backend does implement it

# Canvas
self.__raw_title = ""
self.__title_kwargs = {
"fps": "?",
"backend": self.__class__.__name__,
}

self.__is_drawing = False
self._events = EventEmitter()
self._scheduler = None
Expand Down Expand Up @@ -216,12 +223,9 @@ def request_draw(self, draw_function=None):
if self._scheduler is not None:
self._scheduler.request_draw()

# todo: maybe requesting a new draw can be done by setting a field in an event?
# todo: can just make the draw_function a handler for the draw event?
# -> Note that the draw func is likely to hold a ref to the canvas. By storing it
# here, the circular ref can be broken. This fails if we'd store _draw_frame on the
# scheduler! So with a draw event, we should provide the context and more info so
# that a draw funcion does not need the canvas object.
# -> Note that the draw func is likely to hold a ref to the canvas. By
# storing it here, the gc can detect this case, and its fine. However,
# this fails if we'd store _draw_frame on the scheduler!

def force_draw(self):
"""Perform a draw right now."""
Expand Down Expand Up @@ -256,7 +260,13 @@ def _draw_frame_and_present(self):

# Notify the scheduler
if self._scheduler is not None:
self._scheduler.on_draw()
fps = self._scheduler.on_draw()

# Maybe update title
if fps is not None:
self.__title_kwargs["fps"] = f"{fps:0.1f}"
if "$fps" in self.__raw_title:
self.set_title(self.__raw_title)

# Perform the user-defined drawing code. When this errors,
# we should report the error and then continue, otherwise we crash.
Expand Down Expand Up @@ -341,6 +351,12 @@ def set_logical_size(self, width, height):

def set_title(self, title):
"""Set the window title."""
self.__raw_title = title
for k, v in self.__title_kwargs.items():
title = title.replace("$" + k, v)
self._set_title(title)

def _set_title(self, title):
pass


Expand Down
8 changes: 6 additions & 2 deletions wgpu/gui/glfw.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,9 +151,10 @@ def __init__(self, *, size=None, title=None, **kwargs):
super().__init__(**kwargs)

# Handle inputs
if title is None:
title = "glfw canvas"
if not size:
size = 640, 480
title = str(title or "glfw wgpu canvas")

# Set window hints
glfw.window_hint(glfw.CLIENT_API, glfw.NO_API)
Expand Down Expand Up @@ -195,7 +196,10 @@ def __init__(self, *, size=None, title=None, **kwargs):
# Initialize the size
self._pixel_ratio = -1
self._screen_size_is_logical = False

# Apply incoming args via the proper route
self.set_logical_size(*size)
self.set_title(title)

# Callbacks to provide a minimal working canvas for wgpu

Expand Down Expand Up @@ -323,7 +327,7 @@ def set_logical_size(self, width, height):
raise ValueError("Window width and height must not be negative")
self._set_logical_size((float(width), float(height)))

def set_title(self, title):
def _set_title(self, title):
glfw.set_window_title(self._window, title)

def close(self):
Expand Down
7 changes: 4 additions & 3 deletions wgpu/gui/jupyter.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ def set_logical_size(self, width, height):
self.css_width = f"{width}px"
self.css_height = f"{height}px"

def set_title(self, title):
def _set_title(self, title):
pass # not supported yet

def close(self):
Expand All @@ -97,9 +97,10 @@ def _request_draw(self):

def _force_draw(self):
# A bit hacky to use the internals of jupyter_rfb this way.
# This pushes frames to the browser. The only thing holding
# this back it the websocket buffer. It works!
# 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()
if array is not None:
self._rfb_send_frame(array)
Expand Down
3 changes: 1 addition & 2 deletions wgpu/gui/offscreen.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ def __init__(self, *args, size=None, pixel_ratio=1, title=None, **kwargs):
super().__init__(*args, **kwargs)
self._logical_size = (float(size[0]), float(size[1])) if size else (640, 480)
self._pixel_ratio = pixel_ratio
self._title = title
self._closed = False
self._last_image = None

Expand All @@ -38,7 +37,7 @@ def get_physical_size(self):
def set_logical_size(self, width, height):
self._logical_size = width, height

def set_title(self, title):
def _set_title(self, title):
pass

def close(self):
Expand Down
Loading

0 comments on commit b1096ef

Please sign in to comment.