Files
sx/library/modules/ui/glyph_cache.sx
agra 59f0aa7716 std: restructure — std/ modules, namespace tail, std/xml.sx
allocators/fs/process/socket/log/trace/test move under modules/std/
(allocators.sx becomes std/mem.sx; the Allocator protocol moves into
the std.sx prelude, impls stay in mem.sx). New std/xml.sx holds
xml_escape as xml.escape. std.sx gains the carried namespace tail —
flat-importing std.sx now also provides mem./xml./log. — with the
remaining modules (fs/process/socket/json/cli/hash/test) deferred from
the tail until the global last-wins maps are fully own-wins (pulling
them into every closure collides bare names corpus-wide; they stay
direct imports: modules/std/fs.sx etc.). log.sx's internal emit
renamed log_emit (it clobbered consumer fns named emit program-wide).
bundle.sx uses xml.escape via the carried alias. Consumer import paths
swept mechanically; .ir snapshots recaptured for the larger std
closure. m3te + game build unchanged.
2026-06-11 06:10:59 +03:00

661 lines
22 KiB
Plaintext
Executable File

#import "modules/std.sx";
#import "modules/std/mem.sx";
#import "modules/opengl.sx";
#import "modules/gpu/types.sx";
#import "modules/gpu/api.sx";
#import "modules/stb_truetype.sx";
#import "modules/stb.sx";
#import "modules/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;
// Allocator that owns every dynamically-grown buffer on this cache —
// entries list, hash table, shaped_buf. Captured at init time so growth
// never accidentally lands in a transient per-frame arena.
parent_allocator: Allocator;
// GPU protocol backend. When set, atlas creation + dirty uploads route
// through `gpu` instead of raw GL.
gpu: ?GPU = null;
init :: (self: *GlyphCache, path: [:0]u8, default_size: f32) {
// Preserve any pre-set GPU dispatch across the zero-out — the
// surrounding struct memset would otherwise wipe it.
saved_gpu := self.gpu;
// Zero out the entire struct first (parent may be uninitialized with = ---)
memset(self, 0, size_of(GlyphCache));
self.gpu = saved_gpu;
self.parent_allocator = context.allocator;
// Load font file
file_size : s32 = 0;
font_data := read_file_bytes(path, @file_size);
if font_data == null {
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 = self.parent_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 self.shape_ctx != null {
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 self.parent_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 self.parent_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 self.parent_allocator.alloc(val_bytes);
// Create the atlas texture. In GPU-protocol mode we create empty and
// let the first `flush()` push the (zero-initialized) bitmap via
// update_texture_region — same result as the GL path's glTexImage2D
// with the zeroed bitmap, but works whether or not the backend
// accepts CPU pixel pointers at create time.
if self.gpu != null {
self.texture_id = self.gpu.create_texture(
self.atlas_width, self.atlas_height, .r8, null);
self.dirty = true;
} else {
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.parent_allocator);
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.parent_allocator);
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 self.parent_allocator.alloc(hash_bytes);
memset(self.hash_keys, 0, hash_bytes);
val_bytes : s64 = self.hash_cap * 8;
self.hash_vals = xx self.parent_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;
}
self.parent_allocator.dealloc(old_keys);
self.parent_allocator.dealloc(old_vals);
}
// Upload dirty atlas to GPU. On the Metal path, defer the upload to
// end-of-frame (`upload_atlas_to_gpu`) — calling `replaceRegion:` against
// the same R8 MTLTexture multiple times within one frame garbles the
// contents on iOS-sim Metal. The dirty flag carries over so the final
// end-of-frame upload picks up every rasterization that happened during
// the frame's render pass.
flush :: (self: *GlyphCache) {
if self.dirty == false { return; }
if self.gpu != null { 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;
}
upload_atlas_to_gpu :: (self: *GlyphCache) {
if self.gpu == null { return; }
if self.dirty == false { return; }
self.gpu.update_texture_region(self.texture_id, 0, 0,
self.atlas_width, self.atlas_height, xx 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 self.parent_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;
}
self.parent_allocator.dealloc(self.bitmap);
self.bitmap = new_bitmap;
self.atlas_width = new_w;
self.atlas_height = new_h;
// Recreate atlas at the new size.
if self.gpu != null {
self.gpu.destroy_texture(self.texture_id);
self.texture_id = self.gpu.create_texture(new_w, new_h, .r8, xx new_bitmap);
} else {
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
}, self.parent_allocator);
total += adv;
i += 1;
}
}
shape_with_kb :: (self: *GlyphCache, text: string, font_size: f32) {
if self.shape_ctx == null {
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 = null;
while kbts_GlyphIteratorNext(xx @run.glyphs, xx @glyph_ptr) != 0 {
if glyph_ptr == null { 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
}, self.parent_allocator);
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) }
}
}