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
|
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:
|
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)
|
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:
|
if not state.playback_hud_visible:
|
||||||
return
|
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
|
top_h = layout.playback_hud_top
|
||||||
bottom_h = layout.playback_hud_bottom
|
bottom_h = layout.playback_hud_bottom
|
||||||
width = layout.width
|
width = layout.width
|
||||||
@@ -245,44 +320,42 @@ def draw_playback(renderer, font, state: "BrowserState", layout: theme.Layout, i
|
|||||||
time_x = width - left - time_w
|
time_x = width - left - time_w
|
||||||
title_x = left + layout.playback_status_w + layout.playback_hud_icon_size + 8
|
title_x = left + layout.playback_status_w + layout.playback_hud_icon_size + 8
|
||||||
title_w = max(0, time_x - title_x - 12)
|
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, 0, width, top_h, theme.PLAYBACK_OVERLAY_BG)
|
||||||
_fill_rect(renderer, 0, height - bottom_h, width, bottom_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_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)
|
_rt(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,
|
_rt(title_text, title_x, 5, theme.TEXT_COLOR, title_w)
|
||||||
title_x, 5, theme.TEXT_COLOR, title_w)
|
_rt(time_text, time_x, 5, theme.DIM_TEXT, time_w)
|
||||||
_render_text(renderer, font, time_text,
|
|
||||||
time_x, 5, theme.DIM_TEXT, time_w)
|
|
||||||
|
|
||||||
progress_y = height - bottom_h + 10
|
progress_y = height - bottom_h + 10
|
||||||
_draw_progress_bar(renderer, progress_x, progress_y, progress_w, layout.playback_progress_h, progress)
|
_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)
|
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_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 ""
|
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)
|
_rt(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(hud_mode, width - 70, info_y - 1, theme.DIM_TEXT, 60)
|
||||||
|
|
||||||
controls_y = height - layout.playback_bottom_margin
|
controls_y = height - layout.playback_bottom_margin
|
||||||
cx = left
|
cx = left
|
||||||
_render_icon(renderer, volume_icon, cx, controls_y - 1, layout.playback_hud_icon_size)
|
_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
|
cx += 70
|
||||||
_render_icon(renderer, status_icon, cx, controls_y - 1, layout.playback_hud_icon_size)
|
_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
|
cx += 44
|
||||||
_render_icon(renderer, seek_icon, cx, controls_y - 1, layout.playback_hud_icon_size)
|
_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
|
cx += 62
|
||||||
_render_icon(renderer, stop_icon, cx, controls_y - 1, layout.playback_hud_icon_size)
|
_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
|
cx += 42
|
||||||
_render_icon(renderer, hud_icon, cx, controls_y - 1, layout.playback_hud_icon_size)
|
_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:
|
def draw_error(renderer, font, state: "BrowserState", layout: theme.Layout) -> None:
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ class SDLApp:
|
|||||||
self._playback_font = None
|
self._playback_font = None
|
||||||
self._icons: dict = {}
|
self._icons: dict = {}
|
||||||
self._layout = theme.DEFAULT_LAYOUT
|
self._layout = theme.DEFAULT_LAYOUT
|
||||||
|
self._hud_cache: screens.HUDTextCache | None = None
|
||||||
self._playback_clear_once = False
|
self._playback_clear_once = False
|
||||||
self._needs_redraw = True
|
self._needs_redraw = True
|
||||||
self._last_playback_draw_at = 0.0
|
self._last_playback_draw_at = 0.0
|
||||||
@@ -113,6 +114,7 @@ class SDLApp:
|
|||||||
self._reload_fonts()
|
self._reload_fonts()
|
||||||
if not self._font:
|
if not self._font:
|
||||||
log.error("Could not load any font – UI text will be missing.")
|
log.error("Could not load any font – UI text will be missing.")
|
||||||
|
self._hud_cache = screens.HUDTextCache()
|
||||||
self._icons = self._load_icons()
|
self._icons = self._load_icons()
|
||||||
self._player.attach_window(self._window)
|
self._player.attach_window(self._window)
|
||||||
self._sync_player_viewport()
|
self._sync_player_viewport()
|
||||||
@@ -169,6 +171,8 @@ class SDLApp:
|
|||||||
ttf.TTF_CloseFont(self._playback_font)
|
ttf.TTF_CloseFont(self._playback_font)
|
||||||
self._font = self._load_font(self._layout.font_size)
|
self._font = self._load_font(self._layout.font_size)
|
||||||
self._playback_font = self._load_font(self._layout.playback_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]:
|
def _preferred_window_size(self) -> tuple[int, int]:
|
||||||
mode = sdl2.SDL_DisplayMode()
|
mode = sdl2.SDL_DisplayMode()
|
||||||
@@ -190,6 +194,9 @@ class SDLApp:
|
|||||||
self._reload_fonts()
|
self._reload_fonts()
|
||||||
|
|
||||||
def _cleanup(self) -> None:
|
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():
|
for tex in self._icons.values():
|
||||||
sdl2.SDL_DestroyTexture(tex)
|
sdl2.SDL_DestroyTexture(tex)
|
||||||
self._icons = {}
|
self._icons = {}
|
||||||
@@ -402,7 +409,7 @@ class SDLApp:
|
|||||||
screens.draw_browse_list(r, f, self._state, self._layout, self._icons)
|
screens.draw_browse_list(r, f, self._state, self._layout, self._icons)
|
||||||
elif self._screen == Screen.PLAYBACK:
|
elif self._screen == Screen.PLAYBACK:
|
||||||
self._player.render(r)
|
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:
|
elif self._screen == Screen.ERROR:
|
||||||
screens.draw_error(r, f, self._state, self._layout)
|
screens.draw_error(r, f, self._state, self._layout)
|
||||||
|
|
||||||
|
|||||||
@@ -775,6 +775,7 @@ else:
|
|||||||
import sdl2.sdlttf as _ttf9
|
import sdl2.sdlttf as _ttf9
|
||||||
|
|
||||||
from r36s_dlna_browser.ui import screens as _screens9, theme as _theme9
|
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) ────────────
|
# ── SDL + TTF init (no vsync so phases are timed cleanly) ────────────
|
||||||
sdl2.SDL_SetHint(b"SDL_VIDEODRIVER", b"kmsdrm,offscreen")
|
sdl2.SDL_SetHint(b"SDL_VIDEODRIVER", b"kmsdrm,offscreen")
|
||||||
@@ -906,9 +907,9 @@ else:
|
|||||||
sdl2.SDL_RenderCopy(_ren9, _tex9, None, _dst9)
|
sdl2.SDL_RenderCopy(_ren9, _tex9, None, _dst9)
|
||||||
_t_video = (time.monotonic() - _t0) * 1e6
|
_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()
|
_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
|
_t_hud = (time.monotonic() - _t0) * 1e6
|
||||||
|
|
||||||
# Phase 4 — Present (no vsync → should be near-zero on HW renderer).
|
# Phase 4 — Present (no vsync → should be near-zero on HW renderer).
|
||||||
@@ -928,6 +929,7 @@ else:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
# Cleanup.
|
# Cleanup.
|
||||||
|
_cache9.invalidate()
|
||||||
for _itex_v in _icons9.values():
|
for _itex_v in _icons9.values():
|
||||||
sdl2.SDL_DestroyTexture(_itex_v)
|
sdl2.SDL_DestroyTexture(_itex_v)
|
||||||
if _tex9:
|
if _tex9:
|
||||||
@@ -955,9 +957,9 @@ else:
|
|||||||
print(" --- Section 9 HUD Timing Report ---")
|
print(" --- Section 9 HUD Timing Report ---")
|
||||||
print(f" Frames measured (excl warmup) : {len(_hud9)}")
|
print(f" Frames measured (excl warmup) : {len(_hud9)}")
|
||||||
_stat9("SDL_UpdateNVTexture (synthetic NV12)", _upload9)
|
_stat9("SDL_UpdateNVTexture (synthetic NV12)", _upload9)
|
||||||
_stat9("SDL_RenderClear+RenderCopy (video)", _video9)
|
_stat9("SDL_RenderClear+RenderCopy (video)", _video9)
|
||||||
_stat9("draw_playback() — TTF+fills+icons", _hud9)
|
_stat9("draw_playback() — cached TTF+fills+icons", _hud9)
|
||||||
_stat9("SDL_RenderPresent (no vsync)", _pres9)
|
_stat9("SDL_RenderPresent (no vsync)", _pres9)
|
||||||
|
|
||||||
_hud_mean = statistics.mean(_hud9) if _hud9 else 0
|
_hud_mean = statistics.mean(_hud9) if _hud9 else 0
|
||||||
_total9_mean = (statistics.mean(_upload9) + statistics.mean(_video9) +
|
_total9_mean = (statistics.mean(_upload9) + statistics.mean(_video9) +
|
||||||
|
|||||||
Reference in New Issue
Block a user