From e8bea064edc98ac406eba970f2687bd20294855d Mon Sep 17 00:00:00 2001 From: dmccoystephenson Date: Sun, 26 Jul 2026 22:04:08 +0000 Subject: [PATCH] perf: cache text fonts per size and initialize the font module on demand drawText built a new pygame.font.Font on every call. Constructing a Font parses and rasterizes the TrueType file (3.899 ms/call measured locally) while the render() it exists to serve costs 0.005 ms, so nearly all of drawText's cost was rebuilt work paid again every frame, by every label, and by every drawButton. Cache one Font per size on the instance instead. A Font that outlives a font.quit()/init() cycle is freed memory and segfaults on use, so the cache is dropped when the font module is observed down or when the display surface object changes (which is what restarting pygame does). drawText also called pygame.font.Font without ensuring the font module was up, so a consumer who initialized only the display -- all Graphik's own constructor needs -- hit a bare "font not initialized" error from inside the library. The text path now initializes the module itself. Co-Authored-By: Claude Opus 5 (1M context) --- .../python/preponderous/graphik/graphik.py | 38 ++++++++- .../preponderous/graphik/test_graphik.py | 83 +++++++++++++++++++ 2 files changed, 120 insertions(+), 1 deletion(-) diff --git a/src/main/python/preponderous/graphik/graphik.py b/src/main/python/preponderous/graphik/graphik.py index 94cbc8f..a4730a0 100644 --- a/src/main/python/preponderous/graphik/graphik.py +++ b/src/main/python/preponderous/graphik/graphik.py @@ -22,6 +22,10 @@ def __init__(self, gameDisplay=None): displayHeight = 600 gameDisplay = pygame.display.set_mode((displayWidth, displayHeight)) self.gameDisplay = gameDisplay + # Fonts cached by size, plus the display surface they were built + # against. See _getFont for why both are needed. + self._fonts = {} + self._fontDisplay = None def getGameDisplay(self): return self.gameDisplay @@ -32,8 +36,40 @@ def getVersion(self): def drawRectangle(self, xpos, ypos, width, height, color): pygame.draw.rect(self.gameDisplay, color, [xpos, ypos, width, height]) + def _getFont(self, size): + # Building a Font parses and rasterizes the TrueType file, which costs + # far more than the render() it exists to serve, so keep one per size + # instead of rebuilding it on every frame's drawText. + if not pygame.font.get_init(): + # The constructor only sets up a display, so a consumer can reach + # here having never initialized the font module; bring it up rather + # than failing with a bare "font not initialized". Anything already + # cached belongs to the previous font session (see below). + pygame.font.init() + self._fonts.clear() + + # A Font that outlives a font.quit()/init() cycle points at freed + # SDL_ttf memory and segfaults when used, and pygame offers no way to + # test a Font for validity. Restarting pygame drops the display + # surface, so treat a change of that object as a new session and + # rebuild. (A resize returns the same surface, so this does not + # discard the cache on every set_mode.) + # + # Not covered: a consumer that calls pygame.font.quit() followed by + # pygame.font.init() itself, leaving the display alone -- pygame + # exposes nothing that distinguishes that from an untouched module. + # Build a new Graphik after restarting the font module that way. + display = pygame.display.get_surface() + if display is not self._fontDisplay: + self._fonts.clear() + self._fontDisplay = display + + if size not in self._fonts: + self._fonts[size] = pygame.font.Font('freesansbold.ttf', size) + return self._fonts[size] + def drawText(self, text, xpos, ypos, size, color): - myFont = pygame.font.Font('freesansbold.ttf', size) + myFont = self._getFont(size) textSurface = myFont.render(text, True, color) textRectangle = textSurface.get_rect() textRectangle.center = ((xpos, ypos)) diff --git a/src/test/python/preponderous/graphik/test_graphik.py b/src/test/python/preponderous/graphik/test_graphik.py index b6af6c2..c1ca04a 100644 --- a/src/test/python/preponderous/graphik/test_graphik.py +++ b/src/test/python/preponderous/graphik/test_graphik.py @@ -141,6 +141,89 @@ def test_draw_text_blits_non_background_pixels(): assert changed +def _count_font_constructions(monkeypatch): + # Wrap pygame.font.Font so tests can observe how often it is built. + constructed = [] + realFont = pygame.font.Font + + def counting_font(*args, **kwargs): + constructed.append(args) + return realFont(*args, **kwargs) + + monkeypatch.setattr(pygame.font, "Font", counting_font) + return constructed + + +def test_draw_text_reuses_one_font_across_calls_at_the_same_size(monkeypatch): + graphik = _make_graphik() + constructed = _count_font_constructions(monkeypatch) + + for _ in range(5): + graphik.drawText("A", 5, 5, 12, Graphik.white) + + assert len(constructed) == 1 + + +def test_draw_text_builds_a_separate_font_per_size(monkeypatch): + graphik = _make_graphik() + constructed = _count_font_constructions(monkeypatch) + + graphik.drawText("A", 5, 5, 12, Graphik.white) + graphik.drawText("A", 5, 5, 14, Graphik.white) + graphik.drawText("A", 5, 5, 12, Graphik.white) + + # One font per distinct size, and the repeat of size 12 reuses the first. + assert [args[1] for args in constructed] == [12, 14] + + +def test_draw_text_initializes_font_module_when_only_display_was_initialized(): + # The constructor only sets up a display, so a consumer can reasonably + # reach drawText without ever calling pygame.font.init() themselves. + pygame.display.init() + display = pygame.display.set_mode((20, 20)) + graphik = Graphik(display) + pygame.font.quit() + assert not pygame.font.get_init() + + graphik.drawText("A", 10, 10, 12, Graphik.white) + + assert pygame.font.get_init() + + +def test_draw_text_rebuilds_cached_font_after_the_font_module_shuts_down(monkeypatch): + # A Font built before the font module went down is freed memory, so the + # cache must be dropped rather than reused when drawText restarts it. + graphik = _make_graphik() + graphik.drawText("A", 5, 5, 12, Graphik.white) + + pygame.font.quit() + + constructed = _count_font_constructions(monkeypatch) + graphik.drawText("A", 5, 5, 12, Graphik.white) + + assert len(constructed) == 1 + + +def test_draw_text_drops_cached_font_when_the_display_session_changes(monkeypatch): + # Restarting pygame invalidates the cached font too, but leaves the font + # module initialized, so the check above cannot catch it on its own. + graphik = _make_graphik() + graphik.drawText("A", 5, 5, 12, Graphik.white) + + pygame.quit() + pygame.init() + pygame.display.set_mode((20, 20)) + + constructed = _count_font_constructions(monkeypatch) + # The instance still holds the old display surface, so this fails the same + # loud way it did before fonts were cached -- rather than the stale font + # segfaulting first. + with pytest.raises(pygame.error): + graphik.drawText("A", 5, 5, 12, Graphik.white) + + assert len(constructed) == 1 + + def test_draw_button_draws_box_with_given_color(): graphik = _make_graphik((40, 40)) display = graphik.getGameDisplay()