Browse Source

perf: HUDTextCache — reuse text SDL textures, skip when HUD hidden

main
Matteo Benedetto 1 week ago
parent
commit
fe39312cfa
  1. 103
      src/r36s_dlna_browser/ui/screens.py
  2. 9
      src/r36s_dlna_browser/ui/sdl_app.py
  3. 12
      tests/test_video_playback_device.py

103
src/r36s_dlna_browser/ui/screens.py

@ -18,6 +18,66 @@ if TYPE_CHECKING:
from r36s_dlna_browser.dlna.browser_state import BrowserState
class HUDTextCache:
"""Cache SDL text textures so TTF_RenderUTF8_Blended is not called every frame.
The cache maps ``(text, color_tuple)`` to ``(SDL_Texture*, full_w, full_h)``.
Call ``invalidate()`` whenever the renderer or font changes (e.g. on resize
or cleanup) so stale texture pointers are not used.
"""
def __init__(self) -> None:
self._tex_cache: dict[tuple, tuple] = {}
self._fit_cache: dict[tuple, str] = {}
# ------------------------------------------------------------------
def render_text(
self,
renderer: ctypes.c_void_p,
font: ctypes.c_void_p,
text: str,
x: int,
y: int,
color: tuple,
max_w: int = 0,
) -> None:
"""Render *text* at (x, y), reusing a cached texture when possible."""
if not text or not font or not ttf:
return
key = (text, color)
entry = self._tex_cache.get(key)
if entry is None:
encoded = text.encode("utf-8")
surf = ttf.TTF_RenderUTF8_Blended(font, encoded, _color(color))
if not surf:
return
tex = sdl2.SDL_CreateTextureFromSurface(renderer, surf)
w, h = surf.contents.w, surf.contents.h
sdl2.SDL_FreeSurface(surf)
if not tex:
return
entry = (tex, w, h)
self._tex_cache[key] = entry
tex, w, h = entry
if max_w and w > max_w:
w = max_w
sdl2.SDL_RenderCopy(renderer, tex, sdl2.SDL_Rect(0, 0, w, h), sdl2.SDL_Rect(x, y, w, h))
def fit_text(self, font: ctypes.c_void_p, text: str, max_w: int) -> str:
"""Cache the result of _fit_text so TTF_SizeUTF8 is not called every frame."""
key = (text, max_w)
if key not in self._fit_cache:
self._fit_cache[key] = _fit_text(font, text, max_w)
return self._fit_cache[key]
def invalidate(self) -> None:
"""Destroy all cached textures. Call when renderer or font changes."""
for tex, _w, _h in self._tex_cache.values():
sdl2.SDL_DestroyTexture(tex)
self._tex_cache.clear()
self._fit_cache.clear()
def _color(rgba: tuple) -> sdl2.SDL_Color:
return sdl2.SDL_Color(rgba[0], rgba[1], rgba[2], rgba[3] if len(rgba) > 3 else 255)
@ -212,10 +272,25 @@ def draw_browse_list(renderer, font, state: "BrowserState", layout: theme.Layout
)
def draw_playback(renderer, font, state: "BrowserState", layout: theme.Layout, icons: dict | None = None) -> None:
def draw_playback(
renderer,
font,
state: "BrowserState",
layout: theme.Layout,
icons: dict | None = None,
*,
cache: "HUDTextCache | None" = None,
) -> None:
if not state.playback_hud_visible:
return
# Local alias: reuse cached texture when a cache is provided, else create/destroy per call.
def _rt(text: str, x: int, y: int, color: tuple, max_w: int = 0) -> None:
if cache is not None:
cache.render_text(renderer, font, text, x, y, color, max_w)
else:
_render_text(renderer, font, text, x, y, color, max_w)
top_h = layout.playback_hud_top
bottom_h = layout.playback_hud_bottom
width = layout.width
@ -245,44 +320,42 @@ def draw_playback(renderer, font, state: "BrowserState", layout: theme.Layout, i
time_x = width - left - time_w
title_x = left + layout.playback_status_w + layout.playback_hud_icon_size + 8
title_w = max(0, time_x - title_x - 12)
title_text = _fit_text(font, state.playback_title or "...", title_w)
title_text = cache.fit_text(font, state.playback_title or "...", title_w) if cache else _fit_text(font, state.playback_title or "...", title_w)
_fill_rect(renderer, 0, 0, width, top_h, theme.PLAYBACK_OVERLAY_BG)
_fill_rect(renderer, 0, height - bottom_h, width, bottom_h, theme.PLAYBACK_OVERLAY_BG)
_render_icon(renderer, status_icon, left, max(5, top_h // 5), layout.playback_hud_icon_size)
_render_text(renderer, font, status, left + layout.playback_hud_icon_size + 6, max(4, top_h // 6), theme.TEXT_COLOR, layout.playback_status_w)
_render_text(renderer, font, title_text,
title_x, 5, theme.TEXT_COLOR, title_w)
_render_text(renderer, font, time_text,
time_x, 5, theme.DIM_TEXT, time_w)
_rt(status, left + layout.playback_hud_icon_size + 6, max(4, top_h // 6), theme.TEXT_COLOR, layout.playback_status_w)
_rt(title_text, title_x, 5, theme.TEXT_COLOR, title_w)
_rt(time_text, time_x, 5, theme.DIM_TEXT, time_w)
progress_y = height - bottom_h + 10
_draw_progress_bar(renderer, progress_x, progress_y, progress_w, layout.playback_progress_h, progress)
info_y = height - bottom_h + max(20, bottom_h // 2 - layout.playback_hud_icon_size // 2)
_render_icon(renderer, volume_icon, left, info_y + 1, layout.playback_hud_icon_size)
_render_text(renderer, font, f"{state.playback_volume}%", left + 22, info_y - 1, theme.TEXT_COLOR, 64)
_rt(f"{state.playback_volume}%", left + 22, info_y - 1, theme.TEXT_COLOR, 64)
buffer_text = f"Buffer {state.playback_buffer_percent}%" if state.playback_buffer_percent < 100 else state.playback_resolution or ""
_render_text(renderer, font, buffer_text, left + max(88, layout.playback_status_w + 18), info_y - 1, theme.DIM_TEXT, layout.playback_buffer_w)
_render_text(renderer, font, hud_mode, width - 70, info_y - 1, theme.DIM_TEXT, 60)
_rt(buffer_text, left + max(88, layout.playback_status_w + 18), info_y - 1, theme.DIM_TEXT, layout.playback_buffer_w)
_rt(hud_mode, width - 70, info_y - 1, theme.DIM_TEXT, 60)
controls_y = height - layout.playback_bottom_margin
cx = left
_render_icon(renderer, volume_icon, cx, controls_y - 1, layout.playback_hud_icon_size)
_render_text(renderer, font, "U/D", cx + 20, controls_y - 2, theme.TEXT_COLOR, 38)
_rt("U/D", cx + 20, controls_y - 2, theme.TEXT_COLOR, 38)
cx += 70
_render_icon(renderer, status_icon, cx, controls_y - 1, layout.playback_hud_icon_size)
_render_text(renderer, font, "A", cx + 20, controls_y - 2, theme.TEXT_COLOR, 18)
_rt("A", cx + 20, controls_y - 2, theme.TEXT_COLOR, 18)
cx += 44
_render_icon(renderer, seek_icon, cx, controls_y - 1, layout.playback_hud_icon_size)
_render_text(renderer, font, "L/R", cx + 20, controls_y - 2, theme.TEXT_COLOR, 34)
_rt("L/R", cx + 20, controls_y - 2, theme.TEXT_COLOR, 34)
cx += 62
_render_icon(renderer, stop_icon, cx, controls_y - 1, layout.playback_hud_icon_size)
_render_text(renderer, font, "B", cx + 20, controls_y - 2, theme.TEXT_COLOR, 18)
_rt("B", cx + 20, controls_y - 2, theme.TEXT_COLOR, 18)
cx += 42
_render_icon(renderer, hud_icon, cx, controls_y - 1, layout.playback_hud_icon_size)
_render_text(renderer, font, "Y", cx + 20, controls_y - 2, theme.TEXT_COLOR, 18)
_rt("Y", cx + 20, controls_y - 2, theme.TEXT_COLOR, 18)
def draw_error(renderer, font, state: "BrowserState", layout: theme.Layout) -> None:

9
src/r36s_dlna_browser/ui/sdl_app.py

@ -61,6 +61,7 @@ class SDLApp:
self._playback_font = None
self._icons: dict = {}
self._layout = theme.DEFAULT_LAYOUT
self._hud_cache: screens.HUDTextCache | None = None
self._playback_clear_once = False
self._needs_redraw = True
self._last_playback_draw_at = 0.0
@ -113,6 +114,7 @@ class SDLApp:
self._reload_fonts()
if not self._font:
log.error("Could not load any font – UI text will be missing.")
self._hud_cache = screens.HUDTextCache()
self._icons = self._load_icons()
self._player.attach_window(self._window)
self._sync_player_viewport()
@ -169,6 +171,8 @@ class SDLApp:
ttf.TTF_CloseFont(self._playback_font)
self._font = self._load_font(self._layout.font_size)
self._playback_font = self._load_font(self._layout.playback_font_size)
if self._hud_cache is not None:
self._hud_cache.invalidate()
def _preferred_window_size(self) -> tuple[int, int]:
mode = sdl2.SDL_DisplayMode()
@ -190,6 +194,9 @@ class SDLApp:
self._reload_fonts()
def _cleanup(self) -> None:
if self._hud_cache is not None:
self._hud_cache.invalidate()
self._hud_cache = None
for tex in self._icons.values():
sdl2.SDL_DestroyTexture(tex)
self._icons = {}
@ -402,7 +409,7 @@ class SDLApp:
screens.draw_browse_list(r, f, self._state, self._layout, self._icons)
elif self._screen == Screen.PLAYBACK:
self._player.render(r)
screens.draw_playback(r, self._playback_font or f, self._state, self._layout, self._icons)
screens.draw_playback(r, self._playback_font or f, self._state, self._layout, self._icons, cache=self._hud_cache)
elif self._screen == Screen.ERROR:
screens.draw_error(r, f, self._state, self._layout)

12
tests/test_video_playback_device.py

@ -775,6 +775,7 @@ else:
import sdl2.sdlttf as _ttf9
from r36s_dlna_browser.ui import screens as _screens9, theme as _theme9
_cache9 = _screens9.HUDTextCache()
# ── SDL + TTF init (no vsync so phases are timed cleanly) ────────────
sdl2.SDL_SetHint(b"SDL_VIDEODRIVER", b"kmsdrm,offscreen")
@ -906,9 +907,9 @@ else:
sdl2.SDL_RenderCopy(_ren9, _tex9, None, _dst9)
_t_video = (time.monotonic() - _t0) * 1e6
# Phase 3 — draw_playback() — all TTF_RenderUTF8_Blended + fill-rects.
# Phase 3 — draw_playback() with texture cache (warm after first frame).
_t0 = time.monotonic()
_screens9.draw_playback(_ren9, _font9, _state9, _layout9, _icons9 or None)
_screens9.draw_playback(_ren9, _font9, _state9, _layout9, _icons9 or None, cache=_cache9)
_t_hud = (time.monotonic() - _t0) * 1e6
# Phase 4 — Present (no vsync → should be near-zero on HW renderer).
@ -928,6 +929,7 @@ else:
pass
# Cleanup.
_cache9.invalidate()
for _itex_v in _icons9.values():
sdl2.SDL_DestroyTexture(_itex_v)
if _tex9:
@ -955,9 +957,9 @@ else:
print(" --- Section 9 HUD Timing Report ---")
print(f" Frames measured (excl warmup) : {len(_hud9)}")
_stat9("SDL_UpdateNVTexture (synthetic NV12)", _upload9)
_stat9("SDL_RenderClear+RenderCopy (video)", _video9)
_stat9("draw_playback() — TTF+fills+icons", _hud9)
_stat9("SDL_RenderPresent (no vsync)", _pres9)
_stat9("SDL_RenderClear+RenderCopy (video)", _video9)
_stat9("draw_playback() — cached TTF+fills+icons", _hud9)
_stat9("SDL_RenderPresent (no vsync)", _pres9)
_hud_mean = statistics.mean(_hud9) if _hud9 else 0
_total9_mean = (statistics.mean(_upload9) + statistics.mean(_video9) +

Loading…
Cancel
Save