mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 22:35:08 +00:00
fix(vegas): smooth Vegas scroll pacing -- whole pixels per refresh, measured refresh, off-thread preview writes (#628)
Vegas scrolls a whole number of pixels per panel refresh, locked to SwapOnVSync, against the refresh the panel really holds (measured from swap gaps), instead of blending sub-pixel positions against the refresh cap. The web preview PNG is encoded off the render thread while scrolling, with writes ordered and retried. On hdpi, late frames fell from 6.3% to 0.7%. See docs/SCROLL_PERFORMANCE.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -58,13 +58,22 @@ class VegasModeConfig:
|
||||
# switched off at the start of every cycle.
|
||||
lead_in_width: int = 0
|
||||
|
||||
# Blend between neighbouring pixel positions so motion happens at the frame
|
||||
# rate rather than the scroll speed. With integer positioning the number of
|
||||
# distinct frames per second equals scroll_speed, so at 50px/s the motion is
|
||||
# 50 discrete 1px steps however fast the loop runs. The trade is a slight
|
||||
# horizontal softening of text, since each frame is a blend of two positions.
|
||||
# Lock motion to the panel: a whole number of pixels per presented frame,
|
||||
# each frame held for a whole number of refreshes, with SwapOnVSync as the
|
||||
# clock (see src/common/scroll_config.py). scroll_speed is snapped to the
|
||||
# nearest speed the panel can show that way. Off falls back to advancing by
|
||||
# elapsed time, which drifts against the refresh and judders.
|
||||
smooth_scroll: bool = True
|
||||
|
||||
# The older way of smoothing: advance by elapsed time and blend the two
|
||||
# neighbouring pixel positions each frame. It looks anti-aliased in the web
|
||||
# preview, but on the panel the blended columns shimmer (the library's
|
||||
# brightness curve makes a 50% blend far dimmer than half), text softens,
|
||||
# and the loop is not tied to the refresh, so it still misses frames.
|
||||
# Measured on a 512x64 chain at 95Hz: 73-89fps, p99 20-28ms. Takes
|
||||
# precedence over smooth_scroll's whole-pixel pacing when on.
|
||||
sub_pixel_blend: bool = False
|
||||
|
||||
# Keep one continuous strip, extending it with the next group of plugins as
|
||||
# the scroll approaches the end, instead of composing a fresh strip and
|
||||
# swapping it in. A swap stops the motion, substitutes every pixel at once
|
||||
@@ -196,6 +205,7 @@ class VegasModeConfig:
|
||||
get('min_content_separation', d.min_content_separation)),
|
||||
min_cut_gap=int(get('min_cut_gap', d.min_cut_gap)),
|
||||
smooth_scroll=get('smooth_scroll', d.smooth_scroll),
|
||||
sub_pixel_blend=bool(get('sub_pixel_blend', d.sub_pixel_blend)),
|
||||
continuous_scroll=get('continuous_scroll', d.continuous_scroll),
|
||||
extend_threshold_screens=float(
|
||||
get('extend_threshold_screens', d.extend_threshold_screens)),
|
||||
@@ -238,6 +248,7 @@ class VegasModeConfig:
|
||||
'min_content_separation': self.min_content_separation,
|
||||
'min_cut_gap': self.min_cut_gap,
|
||||
'smooth_scroll': self.smooth_scroll,
|
||||
'sub_pixel_blend': self.sub_pixel_blend,
|
||||
'continuous_scroll': self.continuous_scroll,
|
||||
'extend_threshold_screens': self.extend_threshold_screens,
|
||||
'auto_trim': self.auto_trim,
|
||||
|
||||
@@ -414,7 +414,6 @@ class VegasModeCoordinator:
|
||||
if not self.start():
|
||||
return False
|
||||
|
||||
frame_interval = self.vegas_config.get_frame_interval()
|
||||
if self.vegas_config.continuous_scroll:
|
||||
# The strip is continuously extended and trimmed, so its width says
|
||||
# nothing about how long to run. This is only how often control
|
||||
@@ -482,7 +481,10 @@ class VegasModeCoordinator:
|
||||
# quarter of the budget spent not rendering. Subtracting the work
|
||||
# already done keeps the pacing target while reclaiming that time,
|
||||
# and yields the GIL either way so other threads still run.
|
||||
# Read every frame: a config change applied mid-iteration can
|
||||
# switch between crisp and blended pacing.
|
||||
frame_elapsed = time.monotonic() - frame_started
|
||||
frame_interval = self.render_pipeline.frame_interval
|
||||
time.sleep(max(0.0, frame_interval - frame_elapsed))
|
||||
|
||||
# Measured before the sleep: time spent working, not pacing.
|
||||
@@ -512,20 +514,20 @@ class VegasModeCoordinator:
|
||||
if current_time - last_fps_log_time >= fps_log_interval:
|
||||
fps = fps_frame_count / (current_time - last_fps_log_time)
|
||||
p99 = _percentile(sorted(frame_times), 0.99)
|
||||
target = self.vegas_config.target_fps
|
||||
target = self.render_pipeline.target_fps
|
||||
degraded = target > 0 and fps < target * _FPS_HEALTHY_FRACTION
|
||||
due = (current_time - self._fps_last_health_log
|
||||
>= _FPS_HEARTBEAT_INTERVAL)
|
||||
if degraded or self._fps_was_degraded or due:
|
||||
logger.info(
|
||||
"Vegas FPS: %.1f (target: %d, frames: %d) p99 %.1fms worst %.1fms",
|
||||
"Vegas FPS: %.1f (target: %.0f, frames: %d) p99 %.1fms worst %.1fms",
|
||||
fps, target, fps_frame_count,
|
||||
p99 * 1000.0, frame_worst * 1000.0
|
||||
)
|
||||
self._fps_last_health_log = current_time
|
||||
else:
|
||||
logger.debug(
|
||||
"Vegas FPS: %.1f (target: %d, frames: %d) p99 %.1fms worst %.1fms",
|
||||
"Vegas FPS: %.1f (target: %.0f, frames: %d) p99 %.1fms worst %.1fms",
|
||||
fps, target, fps_frame_count,
|
||||
p99 * 1000.0, frame_worst * 1000.0
|
||||
)
|
||||
@@ -553,7 +555,7 @@ class VegasModeCoordinator:
|
||||
# main loop's _tick_plugin_updates() finds all intervals already
|
||||
# satisfied on return, so the inter-iteration gap is <1 ms and the
|
||||
# display never shows a frozen frame between iterations.
|
||||
_UPDATE_TICK_FRAMES = max(1, int(self.vegas_config.target_fps * 4)) # every 4 s regardless of FPS
|
||||
_UPDATE_TICK_FRAMES = max(1, int(self.render_pipeline.target_fps * 4)) # every 4 s regardless of FPS
|
||||
if (self._update_callback and
|
||||
frame_count % _UPDATE_TICK_FRAMES == 0 and
|
||||
not self._update_tick_running):
|
||||
|
||||
@@ -13,6 +13,7 @@ from collections import deque
|
||||
from typing import Optional, List, Any, Dict, Deque
|
||||
from PIL import Image
|
||||
|
||||
from src.common.scroll_config import solve_crisp
|
||||
from src.common.scroll_helper import ScrollHelper
|
||||
from src.vegas_mode.config import VegasModeConfig
|
||||
from src.vegas_mode.geometry import separation_gap
|
||||
@@ -43,6 +44,13 @@ class RenderPipeline:
|
||||
# stalls land in separate moments rather than one run of hitches.
|
||||
DEFERRED_DRAIN_INTERVAL = 2.0
|
||||
|
||||
# Swaps timed before trusting a refresh measurement: about a second.
|
||||
REFRESH_SAMPLES = 96
|
||||
# Frames skipped first, while the hold from whatever ran before settles.
|
||||
REFRESH_WARMUP_FRAMES = 8
|
||||
# A panel measured within this fraction of its cap is keeping up with it.
|
||||
REFRESH_TOLERANCE = 0.03
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: VegasModeConfig,
|
||||
@@ -74,6 +82,11 @@ class RenderPipeline:
|
||||
logger
|
||||
)
|
||||
|
||||
# The panel's real refresh rate, measured from our own vsync-blocked
|
||||
# swaps once scrolling starts. None until then; see _measure_refresh.
|
||||
self._measured_hz: Optional[float] = None
|
||||
self._swap_times: Deque[float] = deque(maxlen=self.REFRESH_SAMPLES + 1)
|
||||
|
||||
# Configure scroll helper
|
||||
self._configure_scroll_helper()
|
||||
|
||||
@@ -89,6 +102,8 @@ class RenderPipeline:
|
||||
self._cycle_complete = False
|
||||
self._segments_in_scroll: List[str] = [] # Plugin IDs in current scroll
|
||||
|
||||
# The sub-pixel path's pacing; the crisp path solves its own (frame_interval).
|
||||
self._frame_interval = config.get_frame_interval()
|
||||
self._cycle_start_time = 0.0
|
||||
|
||||
# Statistics
|
||||
@@ -108,9 +123,37 @@ class RenderPipeline:
|
||||
|
||||
def _configure_scroll_helper(self) -> None:
|
||||
"""Configure ScrollHelper with current settings."""
|
||||
self.scroll_helper.set_frame_based_scrolling(self.config.frame_based_scrolling)
|
||||
self.scroll_helper.set_scroll_delay(self.config.scroll_delay)
|
||||
self.scroll_helper.set_sub_pixel_scrolling(self.config.smooth_scroll)
|
||||
self.scroll_helper.set_sub_pixel_scrolling(self.config.sub_pixel_blend)
|
||||
|
||||
# With smooth_scroll the strip moves a whole number of pixels per
|
||||
# presented frame, each frame held for frame_hold panel refreshes, and
|
||||
# SwapOnVSync is the clock -- the same crisp pacing the plugin tickers
|
||||
# use (src/common/scroll_config.py). The time-based path below has no
|
||||
# fixed relation to the refresh: at 90px/s on a panel refreshing at
|
||||
# 95Hz every frame lands 0.95px on, so text is re-blended at a
|
||||
# different phase each refresh, and any frame that misses a vsync is
|
||||
# followed by a double step. Measured on a 512x64 chain: 73fps against
|
||||
# a 95Hz panel, p99 21-28ms -- a visible hitch every few frames.
|
||||
self._crisp = None
|
||||
self._frame_hold = 1
|
||||
# Gaps timed under the old hold would be divided by the new one.
|
||||
self._swap_times.clear()
|
||||
if self.config.smooth_scroll and not self.config.sub_pixel_blend:
|
||||
self._crisp = solve_crisp(self.config.scroll_speed, self._refresh_hz())
|
||||
self._frame_hold = self._crisp.frame_hold
|
||||
self.scroll_helper.set_frame_based_scrolling(False)
|
||||
self.scroll_helper.set_scroll_speed(self._crisp.pixels_per_second)
|
||||
self.scroll_helper.set_pixels_per_frame(self._crisp.pixels_per_frame)
|
||||
logger.info(
|
||||
"Vegas scroll: %s (asked for %d px/s on a %.0fHz panel)",
|
||||
self._crisp.describe(), self.config.scroll_speed, self._refresh_hz()
|
||||
)
|
||||
self._apply_dynamic_duration_settings()
|
||||
return
|
||||
|
||||
self.scroll_helper.set_pixels_per_frame(None)
|
||||
self.scroll_helper.set_frame_based_scrolling(self.config.frame_based_scrolling)
|
||||
|
||||
# Config scroll_speed is always pixels per second, but ScrollHelper
|
||||
# takes it in different units depending on frame_based_scrolling:
|
||||
@@ -125,6 +168,9 @@ class RenderPipeline:
|
||||
self.scroll_helper.set_scroll_speed(pixels_per_frame)
|
||||
else:
|
||||
self.scroll_helper.set_scroll_speed(self.config.scroll_speed)
|
||||
self._apply_dynamic_duration_settings()
|
||||
|
||||
def _apply_dynamic_duration_settings(self) -> None:
|
||||
self.scroll_helper.set_dynamic_duration_settings(
|
||||
enabled=self.config.dynamic_duration_enabled,
|
||||
min_duration=self.config.min_cycle_duration,
|
||||
@@ -132,6 +178,92 @@ class RenderPipeline:
|
||||
buffer=0.1 # 10% buffer
|
||||
)
|
||||
|
||||
def _cap_hz(self) -> float:
|
||||
"""The panel's refresh cap, as the display manager reports it."""
|
||||
try:
|
||||
hz = float(getattr(self.display_manager, 'refresh_hz', 0) or 0)
|
||||
except (TypeError, ValueError):
|
||||
hz = 0.0
|
||||
return hz if hz > 0 else 100.0
|
||||
|
||||
def _refresh_hz(self) -> float:
|
||||
"""The refresh to solve the crisp speed against: measured, else the cap."""
|
||||
return self._measured_hz or self._cap_hz()
|
||||
|
||||
def _measure_refresh(self) -> None:
|
||||
"""Time our swaps to learn the rate the panel really refreshes at.
|
||||
|
||||
limit_refresh_rate_hz is a cap, not a rate. A long single chain cannot
|
||||
reach a high one: 4x128x64 at pwm_bits 8 refreshes at ~95Hz under a
|
||||
120Hz cap. Solving against the cap then picks a speed built for a
|
||||
refresh the panel never delivers -- 90px/s at "120Hz" is 3px every 4
|
||||
refreshes, visibly jumpy, where the real 95Hz allows 1px every refresh.
|
||||
|
||||
SwapOnVSync blocks for frame_hold refreshes, and a swap can only come
|
||||
back late -- a missed vsync lengthens its gap by whole refreshes, never
|
||||
shortens one -- so the low end of the gaps is frame_hold refresh
|
||||
periods: the 10th percentile, as src/common/frame_timing.py uses. The
|
||||
median would track the render loop instead once most frames in the
|
||||
window were late (startup, a prefetch, a recompose), lock in a rate
|
||||
too low, and scroll faster than configured until restart. Measured
|
||||
once: the refresh only changes with the hardware config, which
|
||||
restarts us.
|
||||
"""
|
||||
if self._crisp is None or self._measured_hz is not None:
|
||||
return
|
||||
if getattr(self.display_manager, 'matrix', None) is None:
|
||||
return # No hardware: nothing blocks, so there is nothing to time.
|
||||
if self.stats['frames_rendered'] < self.REFRESH_WARMUP_FRAMES:
|
||||
return
|
||||
self._swap_times.append(time.monotonic())
|
||||
if len(self._swap_times) <= self.REFRESH_SAMPLES:
|
||||
return
|
||||
|
||||
times = list(self._swap_times)
|
||||
gaps = sorted(b - a for a, b in zip(times, times[1:]))
|
||||
period = gaps[len(gaps) // 10]
|
||||
self._swap_times.clear()
|
||||
if period <= 0:
|
||||
return
|
||||
measured = self._frame_hold / period
|
||||
cap = self._cap_hz()
|
||||
if measured >= cap * (1.0 - self.REFRESH_TOLERANCE):
|
||||
self._measured_hz = cap
|
||||
return
|
||||
self._measured_hz = round(measured, 1)
|
||||
logger.info(
|
||||
"Vegas: panel refreshes at %.1fHz, below its %.0fHz cap; "
|
||||
"re-solving the scroll speed for the real rate",
|
||||
self._measured_hz, cap
|
||||
)
|
||||
self._configure_scroll_helper()
|
||||
|
||||
@property
|
||||
def target_fps(self) -> float:
|
||||
"""Frames per second this scroll presents when it keeps up."""
|
||||
if self._crisp is not None:
|
||||
return self._crisp.frames_per_second
|
||||
return float(self.config.target_fps)
|
||||
|
||||
@property
|
||||
def frame_interval(self) -> float:
|
||||
"""Shortest time the render loop should spend on one frame.
|
||||
|
||||
With crisp pacing SwapOnVSync already blocks for frame_hold refreshes,
|
||||
so this is only a floor for when the swap does not block (no hardware,
|
||||
or the emulator). It must not exceed the real refresh period: a loop
|
||||
that sleeps even slightly longer than the panel drifts against it and
|
||||
misses a refresh every few frames -- which is what target_fps 90 on a
|
||||
95Hz panel did. The configured cap is at least the real refresh, so
|
||||
hold / cap never exceeds hold real periods -- the measured rate is
|
||||
deliberately not used here.
|
||||
"""
|
||||
if self._crisp is not None:
|
||||
# 0.9: the floor must sit strictly below the real period, or the
|
||||
# surplus accumulates frame over frame until one misses.
|
||||
return 0.9 * self._frame_hold / self._cap_hz()
|
||||
return self._frame_interval
|
||||
|
||||
def compose_scroll_content(self) -> bool:
|
||||
"""
|
||||
Compose content from stream manager into scrollable image.
|
||||
@@ -547,10 +679,11 @@ class RenderPipeline:
|
||||
self.sync_manager.send_scroll_x(self.scroll_helper.scroll_position)
|
||||
|
||||
# Update scrolling state
|
||||
self.display_manager.set_scrolling_state(True)
|
||||
self.display_manager.set_scrolling_state(True, self._frame_hold)
|
||||
|
||||
# Track statistics
|
||||
self.stats['frames_rendered'] += 1
|
||||
self._measure_refresh()
|
||||
frame_time = time.time() - frame_start
|
||||
self._track_frame_time(frame_time)
|
||||
|
||||
@@ -759,6 +892,7 @@ class RenderPipeline:
|
||||
"""
|
||||
old_fps = self.config.target_fps
|
||||
self.config = new_config
|
||||
self._frame_interval = new_config.get_frame_interval()
|
||||
|
||||
# Reconfigure scroll helper
|
||||
self._configure_scroll_helper()
|
||||
|
||||
Reference in New Issue
Block a user