perf: HUDTextCache — reuse text SDL textures, skip when HUD hidden
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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) +
|
||||
|
||||
Reference in New Issue
Block a user