game-engines: raylib-0001 GetGlyphIndex O(T×G), sdl3-0001 TRACK_RESOURCE O(N²); 2 new defects

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.
This commit is contained in:
russell@unturf.com 2026-03-29 22:04:06 -04:00
parent d9ca5b236f
commit 5dc86a9bb1
4 changed files with 739 additions and 0 deletions

View file

@ -0,0 +1,168 @@
# UNDF: UNDF-2026-000000259
# raylib-0001: GetGlyphIndex — O(T×G) linear scan per character in all text hot paths
## CWE-407 — Algorithmic Complexity
| Field | Value |
|-------|-------|
| ID | raylib-0001 |
| Severity | HIGH |
| Ecosystem | raylib |
| Package | rtext |
| File | `src/rtext.c` |
| Lines | 14531479 (GetGlyphIndex), callers: 1292, 1363, 1420, 1488, 1499 |
| Complexity | O(T × G) per text render/measure call |
| Hot path | Every DrawText, DrawTextEx, MeasureText, MeasureTextEx, DrawTextCodepoints |
## Background
raylib exposes a `Font` struct that holds an array of `GlyphInfo[]` indexed by
position, not by codepoint value. When text is drawn or measured, each
character must be converted from its Unicode codepoint to an array index via
`GetGlyphIndex`.
The function is called once per character in every text rendering and
measurement operation, making it the innermost operation in all text paths.
## Defect
`GetGlyphIndex` performs a linear scan over the entire glyph array to find the
matching codepoint:
```c
// src/rtext.c:1453
int GetGlyphIndex(Font font, int codepoint)
{
int index = 0;
if (!IsFontValid(font)) return index;
#define SUPPORT_UNORDERED_CHARSET
#if defined(SUPPORT_UNORDERED_CHARSET)
int fallbackIndex = 0; // Get index of fallback glyph '?'
// Look for character index in the unordered charset
for (int i = 0; i < font.glyphCount; i++)
{
if (font.glyphs[i].value == 63) fallbackIndex = i;
if (font.glyphs[i].value == codepoint)
{
index = i;
break;
}
}
if ((index == 0) && (font.glyphs[0].value != codepoint)) index = fallbackIndex;
#else
index = codepoint - 32;
#endif
return index;
}
```
The `#define SUPPORT_UNORDERED_CHARSET` is defined right before the `#if` check,
so this branch **always executes** and the O(1) fast path is dead code.
Call sites that call `GetGlyphIndex` inside per-character loops:
| Function | File | Hot loop |
|----------|------|----------|
| `DrawTextCodepoints` | rtext.c:1290 | `for (int i = 0; i < codepointCount; i++)` |
| `MeasureTextEx` | rtext.c:1357 | `for (int i = 0; i < size;)` |
| `MeasureTextCodepoints` | rtext.c:1417 | `for (int i = 0; i < length; i++)` |
| `DrawTextCodepoint` (via DrawText) | rtext.c:1263 | called per character |
With a font containing G glyphs and a string of T characters, every draw or
measure call costs O(T × G) comparisons. A full Unicode font (G ≈ 400010000
glyphs) rendering a paragraph (T ≈ 500 chars) performs 25 million comparisons
per frame just for text layout.
## Fix
Build a codepoint→index lookup table at font load time. raylib already has
`rlHashTable` or can use a simple sorted array with binary search. The
simplest approach is a hash table keyed by codepoint:
**Option A — hash map (O(1) lookup after O(G) build):**
```c
// In Font struct (raylib.h), add:
// int *glyphIndex; // hash table: codepoint → glyph array index
// At font load time (LoadFontData / GenImageFontAtlas):
void BuildGlyphIndex(Font *font)
{
// Allocate power-of-two hash table sized 2× glyph count
int tableSize = 1;
while (tableSize < font->glyphCount * 2) tableSize <<= 1;
font->glyphIndex = (int *)RL_CALLOC(tableSize, sizeof(int));
int mask = tableSize - 1;
// sentinel: -1 = empty slot
for (int i = 0; i < tableSize; i++) font->glyphIndex[i] = -1;
for (int i = 0; i < font->glyphCount; i++)
{
int h = (font->glyphs[i].value * 2654435761u) & mask;
while (font->glyphIndex[h] != -1) h = (h + 1) & mask;
font->glyphIndex[h] = i;
}
}
// GetGlyphIndex becomes O(1):
int GetGlyphIndex(Font font, int codepoint)
{
if (!IsFontValid(font)) return 0;
if (!font.glyphIndex) { /* fallback linear scan */ }
int tableSize = /* stored in Font or inferred */ ...;
int mask = tableSize - 1;
int h = ((unsigned)codepoint * 2654435761u) & mask;
while (font.glyphIndex[h] != -1)
{
int i = font.glyphIndex[h];
if (font.glyphs[i].value == codepoint) return i;
h = (h + 1) & mask;
}
return 0; // fallback to '?'
}
```
**Option B — sort glyphs array by codepoint at load time and use binary search
(O(log G) lookup, zero additional memory):**
```c
// Sort once at load:
int CompareGlyphs(const void *a, const void *b) {
return ((GlyphInfo *)a)->value - ((GlyphInfo *)b)->value;
}
qsort(font->glyphs, font->glyphCount, sizeof(GlyphInfo), CompareGlyphs);
// Binary search in GetGlyphIndex replaces the #else branch:
// Remove #define SUPPORT_UNORDERED_CHARSET and use binary search always.
```
Option B is the minimal-change fix and eliminates the `SUPPORT_UNORDERED_CHARSET`
dead-code hazard.
## Speedup
| G (font glyph count) | T (chars per frame) | Before (ops) | After (ops) | Speedup |
|----------------------|---------------------|--------------|-------------|---------|
| 95 (ASCII) | 500 | 47,500 | 500 | 95× |
| 1000 (extended) | 500 | 500,000 | 500 | 1000× |
| 4000 (CJK subset) | 500 | 2,000,000 | 500 | 4000× |
| 10,000 (full Unicode) | 500 | 5,000,000 | 500 | 10,000× |
For ASCII-only games the improvement is ~95× per draw call. For
internationalized titles using large Unicode fonts the pathology reaches
millions of comparisons per frame, making text rendering a bottleneck even at
low scene complexity.
## Note
The `#define SUPPORT_UNORDERED_CHARSET` pragma immediately before the `#if`
check ensures the efficient `index = codepoint - 32` path (the `#else` branch)
**can never execute**. The dead code comment appears to be an old optimization
path left behind when the unordered charset support was added. Removing the
define and restoring the `#else` path would only work for fonts built in ASCII
order starting at codepoint 32, so the hash map or sorted binary search
approach is the correct general fix.

View file

@ -0,0 +1,190 @@
"""
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")