Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
a22815e
Use buffer mapping instead of queue.read_texture
almarklein Nov 11, 2025
540c2d8
bitmap async by lagging one frame
almarklein Nov 11, 2025
e8456fe
delete files I accidentally added
almarklein Nov 12, 2025
eff9217
Merge branch 'main' into async-bitmap
almarklein Nov 14, 2025
c8ac9a8
add note
almarklein Nov 14, 2025
cccdb42
Refactor to implement basic ring buffer
almarklein Nov 14, 2025
7fafc89
polishing a bit
almarklein Nov 14, 2025
6dbb1e1
Re-implement precise sleep
almarklein Nov 17, 2025
ecd3804
improve accuracy of raw loop
almarklein Nov 17, 2025
b91a4ac
ruff
almarklein Nov 17, 2025
39903dc
docs
almarklein Nov 17, 2025
dd3b330
Try a scheduler thread util
almarklein Nov 18, 2025
2b74282
improvinh
almarklein Nov 18, 2025
8554cda
Also apply for trio
almarklein Nov 18, 2025
493924e
tiny tweak
almarklein Nov 18, 2025
aba63cc
Implement for wx
almarklein Nov 19, 2025
2591017
Make precise timers and threaded timers work for all qt backends
almarklein Nov 19, 2025
19aa7a4
Clean up
almarklein Nov 19, 2025
9a01953
add comment
almarklein Nov 19, 2025
6270abb
simplify thread code a bit
almarklein Nov 19, 2025
c06c5c5
Avoid using Future.set_result, which we are not supposed to be calling
almarklein Nov 19, 2025
5326bab
clean
almarklein Nov 19, 2025
eeabd01
comment
almarklein Nov 19, 2025
8f6bbda
Using the thread, the raw loop can become dead simple
almarklein Nov 19, 2025
886e6d4
cleanup
almarklein Nov 19, 2025
f1b89fb
Merge branch 'main' into precise-sleep
almarklein Nov 20, 2025
7602bba
Merge branch 'precise-sleep' into async-bitmap
almarklein Nov 20, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions rendercanvas/_coreutils.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,19 @@
import re
import sys
import time
import queue
import weakref
import logging
import threading
import ctypes.util
from contextlib import contextmanager
from collections import namedtuple


# %% Constants


IS_WIN = sys.platform.startswith("win") # Note that IS_WIN is false on Pyodide


# %% Logging
Expand Down Expand Up @@ -93,6 +102,122 @@ def proxy(*args, **kwargs):
return proxy


# %% Helper for scheduling call-laters


class CallLaterThread(threading.Thread):
"""An object that can be used to do "call later" from a dedicated thread.

Care is taken to realize precise timing, so it can be used to implement
precise sleeping and call_later on Windows (to overcome Windows' notorious
15.6ms ticks).
"""

Item = namedtuple("Item", ["time", "index", "callback", "args"])

def __init__(self):
super().__init__()
self._queue = queue.SimpleQueue()
self._count = 0
self.daemon = True # don't let this thread prevent shutdown
self.start()

def call_later_from_thread(self, delay, callback, *args):
"""In delay seconds, call the callback from the scheduling thread."""
self._count += 1
item = CallLaterThread.Item(
time.perf_counter() + float(delay), self._count, callback, args
)
self._queue.put(item)

def run(self):
perf_counter = time.perf_counter
Empty = queue.Empty # noqa: N806
q = self._queue
priority = []
is_win = IS_WIN

wait_until = None
timestep = 0.001 # for doing small sleeps
leeway = timestep / 2 # a little offset so waiting exactly right on average

while True:
# == Wait for input

if wait_until is None:
# Nothing to do but wait
new_item = q.get(True, None)
else:
# We wait for the queue with a timeout. But because the timeout is not very precise,
# we wait shorter, and then go in a loop with some hard sleeps.
# Windows has 15.6 ms resolution ticks. But also on other OSes,
# it benefits precision to do the last bit with hard sleeps.
offset = 0.016 if is_win else 0.004
try:
new_item = q.get(True, max(0, wait_until - perf_counter() - offset))
except Empty:
new_item = None
while perf_counter() < wait_until:
time.sleep(timestep)
try:
new_item = q.get_nowait()
break
except Empty:
pass

# Put it in our priority queue
if new_item is not None:
priority.append(new_item)
priority.sort(reverse=True)

del new_item

# == Process items until we have to wait

item = None
while True:
# Get item that is up next
try:
item = priority.pop(-1)
except IndexError:
wait_until = None
break

# If it's not yet time for the item, put it back, and go wait
item_time_threshold = item.time - leeway
if perf_counter() < item_time_threshold:
priority.append(item)
wait_until = item_time_threshold
break

# Otherwise, handle the callback
try:
item.callback(*item.args)
except Exception as err:
logger.error(f"Error in CallLaterThread callback: {err}")

del item


_call_later_thread = None


def call_later_from_thread(delay: float, callback: object, *args: object):
"""Utility that calls a callback after a specified delay, from a separate thread.

The caller is responsible for the given callback to be thread-safe.
There is one global thread that handles all callbacks. This thread is spawned the first time
that this function is called.

Note that this function should only be used in environments where threading is available.
E.g. on Pyodide this will raise ``RuntimeError: can't start new thread``.
"""
global _call_later_thread
if _call_later_thread is None:
_call_later_thread = CallLaterThread()
return _call_later_thread.call_later_from_thread(delay, callback, *args)


# %% lib support


Expand Down
21 changes: 3 additions & 18 deletions rendercanvas/_scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,13 @@
The scheduler class/loop.
"""

import sys
import time
import weakref

from ._enums import UpdateMode
from .utils.asyncs import sleep, Event


IS_WIN = sys.platform.startswith("win")


class Scheduler:
"""Helper class to schedule event processing and drawing."""

Expand Down Expand Up @@ -121,20 +117,9 @@ async def __scheduler_task(self):
# Determine amount of sleep
sleep_time = delay - (time.perf_counter() - last_tick_time)

if IS_WIN:
# On Windows OS-level timers have an in accuracy of 15.6 ms.
# This can cause sleep to take longer than intended. So we sleep
# less, and then do a few small sync-sleeps that have high accuracy.
await sleep(max(0, sleep_time - 0.0156))
sleep_time = delay - (time.perf_counter() - last_tick_time)
while sleep_time > 0:
time.sleep(min(sleep_time, 0.001)) # sleep hard for at most 1ms
await sleep(0) # Allow other tasks to run but don't wait
sleep_time = delay - (time.perf_counter() - last_tick_time)
else:
# Wait. Even if delay is zero, it gives control back to the loop,
# allowing other tasks to do work.
await sleep(max(0, sleep_time))
# Wait. Even if delay is zero, it gives control back to the loop,
# allowing other tasks to do work.
await sleep(max(0, sleep_time))

# Below is the "tick"

Expand Down
2 changes: 1 addition & 1 deletion rendercanvas/contexts/bitmapcontext.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
"""

Expand Down
Loading
Loading