raylib-0001: GetGlyphIndex scans all G glyphs per character in every DrawText/MeasureText call — O(T×G) per render. Fix: hash map at load time. 228× speedup at G=1000, 814× at G=4000. UNDF-2026-000000259. sdl3-0001: SDL3 GPU TRACK_RESOURCE macro does linear scan for duplicate check before adding a resource to the command buffer tracked list — O(N²) total across all bind calls per frame. Affects Vulkan, D3D12, and Metal backends identically. Fix: hash set keyed by pointer identity. 41× at N=1000. UNDF-2026-000000273. All 10/10 unit tests PASS.
190 lines
6 KiB
Python
190 lines
6 KiB
Python
"""
|
||
Unit test for raylib-0001: GetGlyphIndex O(T×G) linear scan per character.
|
||
|
||
Tests:
|
||
1. Correctness: both implementations return the same glyph index
|
||
2. Performance: O(G) linear scan vs O(1) hash map lookup ratio
|
||
"""
|
||
import time
|
||
import random
|
||
|
||
|
||
# ---- Minimal glyph stub ----
|
||
|
||
class GlyphInfo:
|
||
def __init__(self, codepoint, advance_x=8):
|
||
self.value = codepoint
|
||
self.advance_x = advance_x
|
||
|
||
|
||
def make_font(codepoints):
|
||
"""Build a fake Font-like object with glyphs in arbitrary order."""
|
||
glyphs = [GlyphInfo(cp) for cp in codepoints]
|
||
random.shuffle(glyphs)
|
||
return glyphs
|
||
|
||
|
||
# ---- BEFORE: defective implementation (linear scan) ----
|
||
|
||
def get_glyph_index_before(glyphs, codepoint):
|
||
"""O(G) linear scan — mirrors raylib src/rtext.c GetGlyphIndex."""
|
||
index = 0
|
||
fallback_index = 0
|
||
for i, g in enumerate(glyphs):
|
||
if g.value == 63: # '?' fallback
|
||
fallback_index = i
|
||
if g.value == codepoint:
|
||
return i
|
||
if glyphs[0].value != codepoint:
|
||
return fallback_index
|
||
return index
|
||
|
||
|
||
def measure_text_before(glyphs, text_codepoints):
|
||
"""Simulate MeasureTextEx: calls get_glyph_index_before per character."""
|
||
total = 0
|
||
for cp in text_codepoints:
|
||
idx = get_glyph_index_before(glyphs, cp)
|
||
total += glyphs[idx].advance_x
|
||
return total
|
||
|
||
|
||
# ---- AFTER: fixed implementation (hash map) ----
|
||
|
||
def build_glyph_hashmap(glyphs):
|
||
"""Build codepoint→index hash map once at font load time."""
|
||
return {g.value: i for i, g in enumerate(glyphs)}
|
||
|
||
|
||
def get_glyph_index_after(glyph_map, glyphs, codepoint):
|
||
"""O(1) hash map lookup."""
|
||
if codepoint in glyph_map:
|
||
return glyph_map[codepoint]
|
||
# fallback to '?'
|
||
if 63 in glyph_map:
|
||
return glyph_map[63]
|
||
return 0
|
||
|
||
|
||
def measure_text_after(glyph_map, glyphs, text_codepoints):
|
||
"""Simulate MeasureTextEx with hash map — O(T) total."""
|
||
total = 0
|
||
for cp in text_codepoints:
|
||
idx = get_glyph_index_after(glyph_map, glyphs, cp)
|
||
total += glyphs[idx].advance_x
|
||
return total
|
||
|
||
|
||
# ---- Tests ----
|
||
|
||
def test_correctness_ascii():
|
||
"""Both implementations return the same index for ASCII glyphs."""
|
||
codepoints = list(range(32, 127)) # 95 ASCII glyphs
|
||
glyphs = make_font(codepoints)
|
||
glyph_map = build_glyph_hashmap(glyphs)
|
||
for cp in codepoints:
|
||
before = get_glyph_index_before(glyphs, cp)
|
||
after = get_glyph_index_after(glyph_map, glyphs, cp)
|
||
assert before == after, (
|
||
f"Mismatch for codepoint {cp}: before={before} after={after}"
|
||
)
|
||
print("PASS test_correctness_ascii")
|
||
|
||
|
||
def test_correctness_unicode():
|
||
"""Both implementations agree on a mixed Unicode glyph set."""
|
||
codepoints = list(range(32, 127)) + list(range(0x4E00, 0x4E00 + 200)) # ASCII + CJK
|
||
glyphs = make_font(codepoints)
|
||
glyph_map = build_glyph_hashmap(glyphs)
|
||
sample = random.sample(codepoints, 50)
|
||
for cp in sample:
|
||
before = get_glyph_index_before(glyphs, cp)
|
||
after = get_glyph_index_after(glyph_map, glyphs, cp)
|
||
assert before == after, (
|
||
f"Mismatch for codepoint {cp}: before={before} after={after}"
|
||
)
|
||
print("PASS test_correctness_unicode")
|
||
|
||
|
||
def test_correctness_measure_text():
|
||
"""MeasureText returns the same width before and after the fix."""
|
||
codepoints = list(range(32, 127))
|
||
glyphs = make_font(codepoints)
|
||
glyph_map = build_glyph_hashmap(glyphs)
|
||
text = [ord(c) for c in "Hello, World! raylib text rendering."]
|
||
w_before = measure_text_before(glyphs, text)
|
||
w_after = measure_text_after(glyph_map, glyphs, text)
|
||
assert w_before == w_after, f"Width mismatch: {w_before} vs {w_after}"
|
||
print("PASS test_correctness_measure_text")
|
||
|
||
|
||
def test_performance_ratio():
|
||
"""O(G) linear scan is dramatically slower than O(1) hash lookup."""
|
||
G = 1000 # glyph count (extended Latin + symbols)
|
||
T = 500 # characters per render call
|
||
ITERS = 100
|
||
|
||
codepoints = list(range(32, 32 + G))
|
||
glyphs = make_font(codepoints)
|
||
glyph_map = build_glyph_hashmap(glyphs)
|
||
text = [codepoints[i % len(codepoints)] for i in range(T)]
|
||
|
||
t0 = time.perf_counter()
|
||
for _ in range(ITERS):
|
||
measure_text_before(glyphs, text)
|
||
t_before = time.perf_counter() - t0
|
||
|
||
t0 = time.perf_counter()
|
||
for _ in range(ITERS):
|
||
measure_text_after(glyph_map, glyphs, text)
|
||
t_after = time.perf_counter() - t0
|
||
|
||
ratio = t_before / t_after if t_after > 0 else float('inf')
|
||
print(f"PERF raylib-0001: G={G} T={T} ITERS={ITERS} "
|
||
f"before={t_before*1000:.1f}ms after={t_after*1000:.1f}ms ratio={ratio:.1f}x")
|
||
|
||
assert ratio >= 50, (
|
||
f"Expected >= 50x speedup for G={G} T={T}, got {ratio:.1f}x"
|
||
)
|
||
print("PASS test_performance_ratio")
|
||
|
||
|
||
def test_performance_large_unicode_font():
|
||
"""With a large Unicode font (G=4000) the pathology is severe."""
|
||
G = 4000
|
||
T = 200
|
||
ITERS = 20
|
||
|
||
codepoints = list(range(0x4E00, 0x4E00 + G)) # CJK range
|
||
glyphs = make_font(codepoints)
|
||
glyph_map = build_glyph_hashmap(glyphs)
|
||
text = [codepoints[i % len(codepoints)] for i in range(T)]
|
||
|
||
t0 = time.perf_counter()
|
||
for _ in range(ITERS):
|
||
measure_text_before(glyphs, text)
|
||
t_before = time.perf_counter() - t0
|
||
|
||
t0 = time.perf_counter()
|
||
for _ in range(ITERS):
|
||
measure_text_after(glyph_map, glyphs, text)
|
||
t_after = time.perf_counter() - t0
|
||
|
||
ratio = t_before / t_after if t_after > 0 else float('inf')
|
||
print(f"PERF raylib-0001 (unicode): G={G} T={T} ITERS={ITERS} "
|
||
f"before={t_before*1000:.1f}ms after={t_after*1000:.1f}ms ratio={ratio:.1f}x")
|
||
|
||
assert ratio >= 500, (
|
||
f"Expected >= 500x speedup for G={G} T={T}, got {ratio:.1f}x"
|
||
)
|
||
print("PASS test_performance_large_unicode_font")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
random.seed(42)
|
||
test_correctness_ascii()
|
||
test_correctness_unicode()
|
||
test_correctness_measure_text()
|
||
test_performance_ratio()
|
||
test_performance_large_unicode_font()
|
||
print("\nAll raylib-0001 tests PASSED")
|