feat(vegas): render plugin content off the render thread, and keep it off the GIL when the panel needs it (#630)

DisplayManager.offscreen() gives a thread its own canvas, so Vegas renders every plugin's ticker content on its prefetch thread instead of pausing the scroll for canvas-bound plugins on the render thread. A render gate (src/common/render_gate.py, vegas_scroll.prefetch_gate, on by default with the GIL-releasing binding) lets the prefetch thread run Python only while the render thread waits in SwapOnVSync: on hdpi, frames 2+ refreshes late fell eightfold and late frames overall from 0.90% to 0.60%. See docs/OFFSCREEN_RENDERING.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-24 19:57:03 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 865d62f67b
commit 9964dd2183
13 changed files with 1903 additions and 89 deletions
+173 -24
View File
@@ -133,6 +133,84 @@ class _LogicalMatrix:
setattr(object.__getattribute__(self, "_matrix"), name, value)
class _OffscreenMatrix(_LogicalMatrix):
"""``display_manager.matrix`` as a thread drawing off-screen sees it.
Reports the surface's size, so plugins that lay out from ``matrix.width``
follow it, and swallows every write that would reach the hardware. Nothing
drawn off-screen may touch the panel the render loop is driving. Method
names mirror the rgbmatrix API they stand in for.
"""
# pylint: disable=invalid-name
__slots__ = ()
def SetImage(self, *_args: Any, **_kwargs: Any) -> None:
"""Inert: off-screen drawing never reaches the panel."""
def SetPixel(self, *_args: Any, **_kwargs: Any) -> None:
"""Inert: off-screen drawing never reaches the panel."""
def Clear(self) -> None:
"""Inert: off-screen drawing never reaches the panel."""
def Fill(self, *_args: Any, **_kwargs: Any) -> None:
"""Inert: off-screen drawing never reaches the panel."""
def SwapOnVSync(self, canvas: Any, *_args: Any, **_kwargs: Any) -> Any:
"""Inert: hands the canvas straight back without waiting on the panel."""
return canvas
def __setattr__(self, name: str, value: Any) -> None:
"""Inert: brightness and other writes stay off the real matrix."""
class _OffscreenSurface:
"""One thread's private canvas while it renders off-screen.
See :meth:`DisplayManager.offscreen`.
"""
__slots__ = ("draw", "image", "matrix")
def __init__(self, width: int, height: int, real_matrix: Any) -> None:
self.image = Image.new('RGB', (width, height))
self.draw = ImageDraw.Draw(self.image)
# 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
self.draw.fontmode = "1"
self.matrix = (_OffscreenMatrix(real_matrix, width, height)
if real_matrix is not None else None)
def _per_thread_canvas_attr(name: str) -> property:
"""A DisplayManager attribute that resolves per thread.
A thread inside :meth:`DisplayManager.offscreen` reads and writes its own
surface's ``name``; every other thread reads and writes the shared value,
exactly as when this was a plain attribute. Existing ``self.image = ...``
assignments therefore keep working and become thread-correct as they are.
"""
shared = "_shared_" + name
def fget(self: "DisplayManager") -> Any:
surface = self._current_surface() # pylint: disable=protected-access
if surface is not None:
return getattr(surface, name)
try:
return self.__dict__[shared]
except KeyError:
raise AttributeError(name) from None
def fset(self: "DisplayManager", value: Any) -> None:
surface = self._current_surface() # pylint: disable=protected-access
if surface is not None:
setattr(surface, name, value)
else:
self.__dict__[shared] = value
return property(fget, fset, doc=f"The plugin-facing ``{name}``, per thread.")
class DisplayManager:
"""
@@ -160,6 +238,11 @@ class DisplayManager:
cls._instance = super(DisplayManager, cls).__new__(cls)
return cls._instance
# The plugin-facing canvas. Per thread: see offscreen().
image = _per_thread_canvas_attr("image")
draw = _per_thread_canvas_attr("draw")
matrix = _per_thread_canvas_attr("matrix")
def __init__(self, config: Dict[str, Any] = None, force_fallback: bool = False, suppress_test_pattern: bool = False):
start_time = time.time()
self.config = config or {}
@@ -173,6 +256,9 @@ class DisplayManager:
# suppress the render loop's own frame pushes for the duration, freezing
# the panel exactly when the point was to avoid a freeze.
self._capture_state = threading.local()
# Per-thread off-screen surface. While a thread is inside offscreen(),
# image, draw and matrix resolve to its own canvas; see offscreen().
self._surface_state = threading.local()
# Double-sided mode state (resolved in _setup_matrix). When disabled,
# the logical image is blitted to the matrix unchanged.
self._double_sided = None # dict {copies, axis, logical_width, logical_height} or None
@@ -238,6 +324,11 @@ class DisplayManager:
# See src/common/scroll_config.py and scripts/scroll_speeds.py.
self._frame_hold = 1
# A src.common.render_gate.RenderGate while Vegas runs with
# vegas_scroll.prefetch_gate on: opened around each swap so the
# prefetch thread only runs Python while this thread waits on vsync.
self.render_gate = None
# Timing of every presented frame, whoever drew it, for
# scripts/frame_soak.py. See src/common/frame_timing.py.
self.frame_timing = FrameTimingRecorder(info=self._frame_timing_info())
@@ -644,7 +735,10 @@ class DisplayManager:
@property
def _capture_mode_active(self) -> bool:
"""True while the calling thread is capturing content off-screen."""
return getattr(self._capture_state, 'active', False)
# Read like _current_surface(): a DisplayManager built without
# __init__ (tests do) has no per-thread state, and captures nothing.
state = self.__dict__.get('_capture_state')
return getattr(state, 'active', False) if state is not None else False
@_capture_mode_active.setter
def _capture_mode_active(self, value: bool) -> None:
@@ -660,11 +754,67 @@ class DisplayManager:
Entering this context prevents those writes without affecting the PIL
image buffer, which the adapter reads to extract content.
"""
# Restore rather than clear: capture_mode() inside offscreen() must not
# switch suppression off for the rest of the off-screen block.
was_active = self._capture_mode_active
self._capture_mode_active = True
try:
yield
finally:
self._capture_mode_active = False
self._capture_mode_active = was_active
def _current_surface(self) -> Optional[_OffscreenSurface]:
"""The calling thread's off-screen surface, or None."""
state = self.__dict__.get('_surface_state')
return getattr(state, 'surface', None) if state is not None else None
def _writes_suppressed(self) -> bool:
"""True when the calling thread must not touch the panel or its pacing."""
return self._capture_mode_active or self._current_surface() is not None
@contextmanager
def offscreen(self, width: Optional[int] = None, height: Optional[int] = None):
"""Give the calling thread its own canvas to draw on.
Inside the block, for the calling thread only, ``image``, ``draw`` and
``matrix`` (and so ``width``/``height``) are a fresh black canvas of the
requested size, and nothing reaches the hardware: ``update_display()``
and the hardware half of ``clear()`` are skipped, and
``set_scrolling_state()``/``set_frame_hold()`` cannot re-pace the live
scroll. Every other thread, the render loop above all, keeps seeing the
real canvas.
That is what lets Vegas mode render a plugin on its background prefetch
thread. The shared canvas used to be the only one, so any plugin that
drew on it (display capture, scroll-content generation, narrowed
rendering) had to be fetched on the render thread, stalling the scroll
for 40-600ms each. See docs/OFFSCREEN_RENDERING.md.
Blocks nest; each restores the one outside it, also on an exception.
Args:
width: Width of the surface, clamped to the size this thread sees
now. Defaults to that size.
height: Height, likewise.
Yields:
The surface. ``surface.image`` is what the plugin drew.
"""
state = self.__dict__.get('_surface_state')
if state is None:
state = self._surface_state = threading.local()
current_w, current_h = self.width, self.height
target_w = max(1, min(int(width), current_w)) if width else current_w
target_h = max(1, min(int(height), current_h)) if height else current_h
surface = _OffscreenSurface(target_w, target_h, self.matrix)
previous = getattr(state, 'surface', None)
state.surface = surface
try:
yield surface
finally:
state.surface = previous
@contextmanager
def render_size(self, width: int, height: Optional[int] = None):
@@ -684,18 +834,14 @@ class DisplayManager:
indirection that double-sided mode relies on, so plugins see a
consistent size from every accessor.
Only meaningful inside :meth:`capture_mode` — this swaps the shared
image buffer, so the render loop must not be writing to it concurrently.
Built on :meth:`offscreen`, so the narrower canvas belongs to the
calling thread alone; the render loop keeps drawing on the real one.
Args:
width: Logical width to report, clamped to at least 1 and to the
real panel width (a larger canvas would overflow the hardware).
height: Logical height, defaulting to the current height.
"""
real_matrix = self.matrix
prev_image = getattr(self, 'image', None)
prev_draw = getattr(self, 'draw', None)
current_w = self.width
current_h = self.height
target_w = max(1, min(int(width), current_w))
@@ -706,19 +852,8 @@ class DisplayManager:
yield
return
try:
if real_matrix is not None:
self.matrix = _LogicalMatrix(real_matrix, target_w, target_h)
# With no hardware, the width/height properties fall through to
# self.image, so swapping the buffer below is enough on its own.
self._new_canvas(target_w, target_h)
with self.offscreen(target_w, target_h):
yield
finally:
self.matrix = real_matrix
if prev_image is not None:
self.image = prev_image
if prev_draw is not None:
self.draw = prev_draw
def _composite_double_sided(self):
"""Tile the logical screen across the full physical chain.
@@ -763,6 +898,12 @@ class DisplayManager:
need to know about it.
"""
try:
if self._writes_suppressed():
# This thread is drawing off-screen. Checked before the lock,
# so it never contends with the render loop's swap, and before
# the fallback branch, so captured content never reaches the
# web preview either.
return
with self._update_lock:
if self.matrix is None:
# Fallback mode - no actual hardware to update
@@ -771,9 +912,6 @@ class DisplayManager:
self._write_snapshot_if_due()
return
if self._capture_mode_active:
return # Skip hardware write — content is being captured off-screen
digest = None
frame_checksum = None
if self._dirty_tracking_enabled:
@@ -816,7 +954,12 @@ class DisplayManager:
# Swap buffers immediately. framerate_fraction holds the frame
# for N refreshes; SwapOnVSync blocks for all of them, which is
# what paces the render loop to the chosen frame rate.
gate = self.render_gate
if gate is not None:
gate.before_swap(self._frame_hold)
self.matrix.SwapOnVSync(self.offscreen_canvas, self._frame_hold)
if gate is not None:
gate.after_swap(self._frame_hold)
presented_at = time.perf_counter()
self.frame_timing.record(
blit_done - blit_started, presented_at - blit_done,
@@ -888,7 +1031,7 @@ class DisplayManager:
self._new_canvas(self.matrix.width, self.matrix.height)
if not self._capture_mode_active:
if not self._writes_suppressed():
# Clear both canvases and the underlying matrix to ensure no artifacts.
# Failures are non-fatal — the image buffer is already black above, so
# the next update_display() call will push clean content regardless.
@@ -1480,6 +1623,8 @@ class DisplayManager:
Reset to 1 whenever scrolling stops, so one plugin's pacing cannot
leak into the next thing on screen.
"""
if self._writes_suppressed():
return # a plugin drawing off-screen cannot re-pace the live scroll
try:
value = int(refreshes)
except (TypeError, ValueError):
@@ -1508,6 +1653,10 @@ class DisplayManager:
the lifetime exactly the scroll, and the default of 1 means any caller
that does not care gets a new frame every refresh.
"""
if self._writes_suppressed():
# A plugin captured for Vegas calls this from its own display();
# it must not change the live scroll's state or frame hold.
return
current_time = time.time()
# Scrolling callers set this every frame; log transitions only.
changed = self._scrolling_state['is_scrolling'] != is_scrolling