Files
game/ui/glyph_cache.sx
2026-03-04 17:17:29 +02:00

613 lines
20 KiB
Plaintext

#import "modules/std.sx";
#import "modules/opengl.sx";
#import "ui/types.sx";
// Cached glyph data with UV coordinates into the atlas texture
CachedGlyph :: struct {
uv_x: f32;
uv_y: f32;
uv_w: f32;
uv_h: f32;
width: f32;
height: f32;
offset_x: f32;
offset_y: f32;
advance: f32;
}
// Cache entry: key + glyph data
GlyphEntry :: struct {
key: u32;
glyph: CachedGlyph;
}
// Quantize font size to half-point increments to limit cache entries.
// e.g., 13.0 -> 26, 13.5 -> 27, 14.0 -> 28
quantize_size :: (font_size: f32) -> u16 {
xx (font_size * 2.0 + 0.5);
}
dequantize_size :: (q: u16) -> f32 {
xx q / 2.0;
}
// Pack (glyph_index, size_quantized) into a single u32 for fast comparison
make_glyph_key :: (glyph_index: u16, size_quantized: u16) -> u32 {
(xx glyph_index << 16) | xx size_quantized;
}
// Shaped glyph — output of text shaping (positioned glyph with index)
ShapedGlyph :: struct {
glyph_index: u16;
x: f32; // horizontal position (logical units, cumulative)
y: f32; // vertical offset (logical units)
advance: f32; // advance width (logical units)
}
is_ascii :: (text: string) -> bool {
i : s64 = 0;
while i < text.len {
if text[i] >= 128 { return false; }
i += 1;
}
true;
}
// kbts constants (C enum values)
KBTS_DIRECTION_DONT_KNOW :u32: 0;
KBTS_LANGUAGE_DONT_KNOW :u32: 0;
KBTS_USER_ID_GENERATION_MODE_CODEPOINT_INDEX :u32: 0;
// SX structs matching kbts C struct layouts (64-bit).
// We define these in SX to access fields directly, casting from opaque C pointers.
KbtsGlyphIterator :: struct {
glyph_storage: *void;
current_glyph: *void;
last_advance_x: s32;
x: s32;
y: s32;
}
KbtsRun :: struct {
font: *void;
script: u32;
paragraph_direction: u32;
direction: u32;
flags: u32;
glyphs: KbtsGlyphIterator;
}
KbtsGlyph :: struct {
prev: *void;
next: *void;
codepoint: u32;
id: u16;
uid: u16;
user_id_or_codepoint_index: s32;
offset_x: s32;
offset_y: s32;
advance_x: s32;
advance_y: s32;
}
// kbts_font_info2 base (simplified — we only need the Size field for dispatch)
KBTS_FONT_INFO_STRING_ID_COUNT :s32: 7;
KbtsFontInfo2 :: struct {
size: u32;
strings: [7]*void; // char* array
string_lengths: [7]u16;
style_flags: u32;
weight: u32;
width: u32;
}
KbtsFontInfo2_1 :: struct {
base: KbtsFontInfo2;
units_per_em: u16;
x_min: s16;
y_min: s16;
x_max: s16;
y_max: s16;
ascent: s16;
descent: s16;
line_gap: s16;
}
GLYPH_ATLAS_W :s32: 1024;
GLYPH_ATLAS_H :s32: 1024;
FONTINFO_SIZE :s64: 256;
PackResult :: struct {
x, y: s32;
}
// Dynamic glyph cache with on-demand rasterization and texture atlas packing.
GlyphCache :: struct {
// Font data
font_info: *void; // heap-allocated stbtt_fontinfo (256 bytes)
font_data: *void; // raw TTF file bytes (kept alive for stbtt)
// Atlas texture (GPU)
texture_id: u32;
atlas_width: s32;
atlas_height: s32;
// Atlas bitmap (CPU-side for updates)
bitmap: [*]u8;
// Shelf packer state
shelf_y: s32;
shelf_height: s32;
cursor_x: s32;
padding: s32;
// Glyph lookup cache
entries: List(GlyphEntry);
// Hash table for O(1) glyph lookup (open addressing, linear probing)
hash_keys: [*]u32; // key per slot (0 = empty sentinel)
hash_vals: [*]s32; // index into entries list
hash_cap: s64; // table capacity (power of 2)
// Dirty tracking for texture upload
dirty: bool;
// Font vertical metrics (at reference size 1.0 — scale by font_size)
ascent: f32;
descent: f32;
line_gap: f32;
// HiDPI: physical pixels per logical pixel (e.g. 2.0 on Retina)
dpi_scale: f32;
inv_dpi: f32;
// Text shaping (kb_text_shape)
shape_ctx: *void;
shape_font: *void;
units_per_em: u16;
font_data_size: s32;
shaped_buf: List(ShapedGlyph);
// Shape cache: skip reshaping if same text + size as last call
last_shape_ptr: [*]u8;
last_shape_len: s64;
last_shape_size_q: u16;
init :: (self: *GlyphCache, path: [:0]u8, default_size: f32) {
// Zero out the entire struct first (parent may be uninitialized with = ---)
memset(self, 0, size_of(GlyphCache));
// Load font file
file_size : s32 = 0;
font_data := read_file_bytes(path, @file_size);
if xx font_data == 0 {
out("Failed to load font: ");
out(path);
out("\n");
return;
}
self.font_data = xx font_data;
self.font_data_size = file_size;
// Init stbtt_fontinfo
self.font_info = context.allocator.alloc(FONTINFO_SIZE);
memset(self.font_info, 0, FONTINFO_SIZE);
stbtt_InitFont(self.font_info, font_data, 0);
// Get font vertical metrics (in unscaled font units)
ascent_i : s32 = 0;
descent_i : s32 = 0;
linegap_i : s32 = 0;
stbtt_GetFontVMetrics(self.font_info, @ascent_i, @descent_i, @linegap_i);
// Store unscaled metrics — we'll scale per font_size in measure_text
self.ascent = xx ascent_i;
self.descent = xx descent_i;
self.line_gap = xx linegap_i;
// Init text shaping context
self.shape_ctx = xx kbts_CreateShapeContext(xx 0, xx 0);
if xx self.shape_ctx != 0 {
self.shape_font = xx kbts_ShapePushFontFromMemory(xx self.shape_ctx, self.font_data, file_size, 0);
// Get font metrics (units_per_em) from kbts
kb_info : KbtsFontInfo2_1 = ---;
memset(@kb_info, 0, size_of(KbtsFontInfo2_1));
kb_info.base.size = xx size_of(KbtsFontInfo2_1);
kbts_GetFontInfo2(xx self.shape_font, xx @kb_info);
self.units_per_em = kb_info.units_per_em;
}
// Allocate atlas bitmap
self.atlas_width = GLYPH_ATLAS_W;
self.atlas_height = GLYPH_ATLAS_H;
bitmap_size : s64 = xx self.atlas_width * xx self.atlas_height;
self.bitmap = xx context.allocator.alloc(bitmap_size);
memset(self.bitmap, 0, bitmap_size);
// Shelf packer init
self.shelf_y = 0;
self.shelf_height = 0;
self.cursor_x = 0;
self.padding = 1;
self.dirty = false;
self.dpi_scale = 1.0;
self.inv_dpi = 1.0;
// Init hash table (256 slots)
self.hash_cap = 256;
hash_bytes : s64 = self.hash_cap * 4; // u32 per slot
self.hash_keys = xx context.allocator.alloc(hash_bytes);
memset(self.hash_keys, 0, hash_bytes);
val_bytes : s64 = self.hash_cap * 8; // s64 per slot (s32 would suffice but alignment)
self.hash_vals = xx context.allocator.alloc(val_bytes);
// Create OpenGL texture
glGenTextures(1, @self.texture_id);
glBindTexture(GL_TEXTURE_2D, self.texture_id);
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
glTexImage2D(GL_TEXTURE_2D, 0, xx GL_R8, self.atlas_width, self.atlas_height, 0, GL_RED, GL_UNSIGNED_BYTE, self.bitmap);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, xx GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, xx GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, xx GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, xx GL_CLAMP_TO_EDGE);
out("GlyphCache initialized: ");
out(path);
out("\n");
}
// Look up or rasterize a glyph, returning a pointer to its cached entry.
// Returns null for glyphs with no outline AND zero advance (shouldn't happen for valid chars).
get_or_rasterize :: (self: *GlyphCache, glyph_index: u16, font_size: f32) -> *CachedGlyph {
size_q := quantize_size(font_size);
key := make_glyph_key(glyph_index, size_q);
// Hash table lookup (open addressing, linear probing)
mask := self.hash_cap - 1;
slot : s64 = xx ((key * 2654435761) >> 24) & xx mask;
while self.hash_keys[slot] != 0 {
if self.hash_keys[slot] == key {
return @self.entries.items[self.hash_vals[slot]].glyph;
}
slot = (slot + 1) & mask;
}
// Cache miss — rasterize
actual_size := dequantize_size(size_q);
scale := stbtt_ScaleForPixelHeight(self.font_info, actual_size);
// Get glyph bounding box
x0 : s32 = 0;
y0 : s32 = 0;
x1 : s32 = 0;
y1 : s32 = 0;
stbtt_GetGlyphBitmapBox(self.font_info, xx glyph_index, scale, scale, @x0, @y0, @x1, @y1);
glyph_w := if x1 > x0 then x1 - x0 else 0;
glyph_h := if y1 > y0 then y1 - y0 else 0;
// Get horizontal metrics
advance_i : s32 = 0;
lsb_i : s32 = 0;
stbtt_GetGlyphHMetrics(self.font_info, xx glyph_index, @advance_i, @lsb_i);
advance : f32 = xx advance_i * scale;
// Zero-size glyph (e.g. space) — cache with advance only
if glyph_w == 0 or glyph_h == 0 {
entry := GlyphEntry.{
key = key,
glyph = CachedGlyph.{
uv_x = 0.0, uv_y = 0.0, uv_w = 0.0, uv_h = 0.0,
width = 0.0, height = 0.0,
offset_x = xx x0, offset_y = xx y0,
advance = advance
}
};
self.entries.append(entry);
self.hash_insert(key, self.entries.len - 1);
return @self.entries.items[self.entries.len - 1].glyph;
}
// Pack into atlas
pack := self.try_pack(glyph_w, glyph_h);
if pack.x < 0 {
// Atlas full — grow and retry
self.grow();
return self.get_or_rasterize(glyph_index, font_size);
}
// Rasterize directly into atlas bitmap
dest_offset : s64 = xx pack.y * xx self.atlas_width + xx pack.x;
stbtt_MakeGlyphBitmap(
self.font_info,
@self.bitmap[dest_offset],
glyph_w, glyph_h,
self.atlas_width,
scale, scale,
xx glyph_index
);
self.dirty = true;
// Compute normalized UV coordinates
atlas_wf : f32 = xx self.atlas_width;
atlas_hf : f32 = xx self.atlas_height;
entry := GlyphEntry.{
key = key,
glyph = CachedGlyph.{
uv_x = xx pack.x / atlas_wf,
uv_y = xx pack.y / atlas_hf,
uv_w = xx glyph_w / atlas_wf,
uv_h = xx glyph_h / atlas_hf,
width = xx glyph_w,
height = xx glyph_h,
offset_x = xx x0,
offset_y = xx y0,
advance = advance
}
};
self.entries.append(entry);
self.hash_insert(key, self.entries.len - 1);
return @self.entries.items[self.entries.len - 1].glyph;
}
// Insert a key→index mapping into the hash table, growing if needed
hash_insert :: (self: *GlyphCache, key: u32, index: s64) {
// Grow if load factor > 70%
if self.entries.len * 10 > self.hash_cap * 7 {
self.hash_grow();
}
mask := self.hash_cap - 1;
slot : s64 = xx ((key * 2654435761) >> 24) & xx mask;
while self.hash_keys[slot] != 0 {
slot = (slot + 1) & mask;
}
self.hash_keys[slot] = key;
self.hash_vals[slot] = xx index;
}
// Double the hash table and rehash all entries
hash_grow :: (self: *GlyphCache) {
old_cap := self.hash_cap;
old_keys := self.hash_keys;
old_vals := self.hash_vals;
self.hash_cap = old_cap * 2;
hash_bytes : s64 = self.hash_cap * 4;
self.hash_keys = xx context.allocator.alloc(hash_bytes);
memset(self.hash_keys, 0, hash_bytes);
val_bytes : s64 = self.hash_cap * 8;
self.hash_vals = xx context.allocator.alloc(val_bytes);
// Rehash
mask := self.hash_cap - 1;
i : s64 = 0;
while i < old_cap {
k := old_keys[i];
if k != 0 {
slot : s64 = xx ((k * 2654435761) >> 24) & xx mask;
while self.hash_keys[slot] != 0 {
slot = (slot + 1) & mask;
}
self.hash_keys[slot] = k;
self.hash_vals[slot] = old_vals[i];
}
i += 1;
}
context.allocator.dealloc(old_keys);
context.allocator.dealloc(old_vals);
}
// Upload dirty atlas to GPU
flush :: (self: *GlyphCache) {
if self.dirty == false { return; }
glBindTexture(GL_TEXTURE_2D, self.texture_id);
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, self.atlas_width, self.atlas_height, GL_RED, GL_UNSIGNED_BYTE, self.bitmap);
self.dirty = false;
}
// Shelf-based rectangle packer.
// Returns PackResult with x >= 0 on success, x = -1 if no space.
try_pack :: (self: *GlyphCache, w: s32, h: s32) -> PackResult {
padded_w := w + self.padding;
padded_h := h + self.padding;
// Try fitting on the current shelf
eff_h := if self.shelf_height > padded_h then self.shelf_height else padded_h;
if self.cursor_x + padded_w <= self.atlas_width and self.shelf_y + eff_h <= self.atlas_height {
result := PackResult.{ x = self.cursor_x, y = self.shelf_y };
self.cursor_x += padded_w;
if padded_h > self.shelf_height {
self.shelf_height = padded_h;
}
return result;
}
// Start a new shelf
new_shelf_y := self.shelf_y + self.shelf_height;
if new_shelf_y + padded_h <= self.atlas_height and padded_w <= self.atlas_width {
self.shelf_y = new_shelf_y;
self.shelf_height = padded_h;
self.cursor_x = padded_w;
return PackResult.{ x = 0, y = new_shelf_y };
}
// No space
PackResult.{ x = 0 - 1, y = 0 - 1 };
}
// Grow the atlas by doubling dimensions
grow :: (self: *GlyphCache) {
new_w := self.atlas_width * 2;
new_h := self.atlas_height * 2;
new_size : s64 = xx new_w * xx new_h;
new_bitmap : [*]u8 = xx context.allocator.alloc(new_size);
memset(new_bitmap, 0, new_size);
// Copy old rows into new bitmap
y : s32 = 0;
while y < self.atlas_height {
old_off : s64 = xx y * xx self.atlas_width;
new_off : s64 = xx y * xx new_w;
memcpy(@new_bitmap[new_off], @self.bitmap[old_off], xx self.atlas_width);
y += 1;
}
context.allocator.dealloc(self.bitmap);
self.bitmap = new_bitmap;
self.atlas_width = new_w;
self.atlas_height = new_h;
// Recreate GL texture
glDeleteTextures(1, @self.texture_id);
glGenTextures(1, @self.texture_id);
glBindTexture(GL_TEXTURE_2D, self.texture_id);
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
glTexImage2D(GL_TEXTURE_2D, 0, xx GL_R8, new_w, new_h, 0, GL_RED, GL_UNSIGNED_BYTE, new_bitmap);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, xx GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, xx GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, xx GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, xx GL_CLAMP_TO_EDGE);
// Recompute UV coordinates for all cached glyphs
atlas_wf : f32 = xx new_w;
atlas_hf : f32 = xx new_h;
i : s64 = 0;
while i < self.entries.len {
g := @self.entries.items[i].glyph;
if g.width > 0.0 {
g.uv_x = g.uv_x / 2.0;
g.uv_y = g.uv_y / 2.0;
g.uv_w = g.width / atlas_wf;
g.uv_h = g.height / atlas_hf;
}
i += 1;
}
self.dirty = false;
out("GlyphCache atlas grown\n");
}
set_dpi_scale :: (self: *GlyphCache, scale: f32) {
self.dpi_scale = scale;
self.inv_dpi = 1.0 / scale;
}
// Get the scale factor for a logical font size
scale_for_size :: (self: *GlyphCache, font_size: f32) -> f32 {
stbtt_ScaleForPixelHeight(self.font_info, font_size);
}
// Get scaled ascent for a logical font size
get_ascent :: (self: *GlyphCache, font_size: f32) -> f32 {
self.ascent * self.scale_for_size(font_size);
}
// Get scaled line height for a logical font size
get_line_height :: (self: *GlyphCache, font_size: f32) -> f32 {
s := self.scale_for_size(font_size);
(self.ascent - self.descent + self.line_gap) * s;
}
// Shape text into positioned glyphs.
// Uses ASCII fast-path (stbtt byte-by-byte) for pure ASCII,
// full kb_text_shape pipeline for Unicode/complex scripts.
// Results stored in self.shaped_buf (reused across calls).
shape_text :: (self: *GlyphCache, text: string, font_size: f32) {
// Check shape cache: skip if same text + size as last call
size_q := quantize_size(font_size);
if text.len > 0 and text.ptr == self.last_shape_ptr and text.len == self.last_shape_len and size_q == self.last_shape_size_q {
return; // shaped_buf already has the result
}
self.shaped_buf.len = 0;
if text.len == 0 { return; }
if is_ascii(text) {
self.shape_ascii(text, font_size);
} else {
self.shape_with_kb(text, font_size);
}
// Update shape cache
self.last_shape_ptr = text.ptr;
self.last_shape_len = text.len;
self.last_shape_size_q = size_q;
}
shape_ascii :: (self: *GlyphCache, text: string, font_size: f32) {
scale := stbtt_ScaleForPixelHeight(self.font_info, font_size);
total : f32 = 0.0;
i : s64 = 0;
while i < text.len {
ch : s32 = xx text[i];
glyph_index : u16 = xx stbtt_FindGlyphIndex(self.font_info, ch);
advance_i : s32 = 0;
lsb_i : s32 = 0;
stbtt_GetGlyphHMetrics(self.font_info, xx glyph_index, @advance_i, @lsb_i);
adv : f32 = xx advance_i * scale;
self.shaped_buf.append(ShapedGlyph.{
glyph_index = glyph_index,
x = total,
y = 0.0,
advance = adv
});
total += adv;
i += 1;
}
}
shape_with_kb :: (self: *GlyphCache, text: string, font_size: f32) {
if xx self.shape_ctx == 0 {
self.shape_ascii(text, font_size);
return;
}
scale : f32 = font_size / xx self.units_per_em;
total : f32 = 0.0;
kbts_ShapeBegin(xx self.shape_ctx, KBTS_DIRECTION_DONT_KNOW, KBTS_LANGUAGE_DONT_KNOW);
kbts_ShapeUtf8(xx self.shape_ctx, xx text.ptr, xx text.len, KBTS_USER_ID_GENERATION_MODE_CODEPOINT_INDEX);
kbts_ShapeEnd(xx self.shape_ctx);
run : KbtsRun = ---;
while kbts_ShapeRun(xx self.shape_ctx, xx @run) != 0 {
glyph_ptr : *KbtsGlyph = xx 0;
while kbts_GlyphIteratorNext(xx @run.glyphs, xx @glyph_ptr) != 0 {
if xx glyph_ptr == 0 { continue; }
gx := total + xx glyph_ptr.offset_x * scale;
gy : f32 = xx glyph_ptr.offset_y * scale;
adv : f32 = xx glyph_ptr.advance_x * scale;
self.shaped_buf.append(ShapedGlyph.{
glyph_index = glyph_ptr.id,
x = gx,
y = gy,
advance = adv
});
total += adv;
}
}
}
// Measure text at a logical font size using text shaping.
// Rasterizes at physical resolution (font_size * dpi_scale), returns logical dimensions.
measure_text :: (self: *GlyphCache, text: string, font_size: f32) -> Size {
self.shape_text(text, font_size);
width : f32 = 0.0;
i : s64 = 0;
while i < self.shaped_buf.len {
width += self.shaped_buf.items[i].advance;
i += 1;
}
Size.{ width = width, height = self.get_line_height(font_size) };
}
}