From b43472e6abec31c8593a8a7944740d7ed8dd054c Mon Sep 17 00:00:00 2001 From: agra Date: Mon, 18 May 2026 10:26:31 +0300 Subject: [PATCH] ui: text shader uses raw atlas coverage (no SDF smoothstep) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two small cleanups in the Metal text path on top of the buffer-offset fix from cc71d95: - Drop the SDF-style `smoothstep(0.5 ± ew, alpha)` from the text mode branch in UI_MSL_SRC. The glyph atlas stores alpha coverage straight from stbtt_MakeGlyphBitmap, not signed distance, so the smoothstep was thinning anti-aliased strokes by mapping mid-coverage values (0.3–0.7) toward 0/1. Use the sampled value directly as alpha. - Drop the 16-byte alignment pad on `mtl_buf_offset` in `flush()`. Each batch's upload_size is already a multiple of UI_VERTEX_BYTES (48), so the running offset stays vertex-aligned without the extra rounding. - After `font.shape_text` + `font.flush` in `render_text`, re-bind `font.texture_id`. If the atlas grew during shaping, the GPU texture handle changed; without this rebind the next flush samples the old (smaller) atlas which doesn't have the newly-rasterized glyphs. - Use explicit s64-pointer arithmetic in `metal_update_buffer_at_ios` so a future regression in `[*]u8` indexing can't quietly miscompile the per-flush write offset. Text at small sizes still renders dim on dark backgrounds — most glyph pixels sit in 0.1–0.5 coverage and the linear blend doesn't push them to bright values — tracked separately as the faint-text follow-up. --- library/modules/gpu/metal.sx | 8 ++++++-- library/modules/ui/renderer.sx | 33 +++++++++++++++++++++++---------- 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/library/modules/gpu/metal.sx b/library/modules/gpu/metal.sx index 407c32b..38e2296 100644 --- a/library/modules/gpu/metal.sx +++ b/library/modules/gpu/metal.sx @@ -462,8 +462,12 @@ metal_update_buffer_at_ios :: (self: *MetalGPU, handle: u32, data: *void, size_b msg_o : (*void, *void) -> *void = xx objc_msgSend; base := msg_o(buf, sel_registerName("contents".ptr)); if base == null { return; } - dst : [*]u8 = xx base; - memcpy(xx @dst[byte_offset], data, size_bytes); + // Add byte_offset via integer arithmetic — `@dst[i]` on `[*]u8` + // already does this, but we keep this form explicit so a future + // pointer-arithmetic regression here can't hide. + base_i : s64 = xx base; + dst_at : *void = xx (base_i + byte_offset); + memcpy(dst_at, data, size_bytes); } metal_lookup_buffer :: (self: *MetalGPU, handle: u32) -> *void { diff --git a/library/modules/ui/renderer.sx b/library/modules/ui/renderer.sx index ce98b53..bcfa500 100755 --- a/library/modules/ui/renderer.sx +++ b/library/modules/ui/renderer.sx @@ -310,11 +310,13 @@ UIRenderer :: struct { self.gpu.update_buffer_at(self.mtl_vbuf, xx self.vertices, upload_size, byte_off); vertex_off : s32 = xx (byte_off / UI_VERTEX_BYTES); self.gpu.draw_triangles(vertex_off, xx self.vertex_count); + // Each batch starts on a vertex boundary so `vertex_off = + // byte_off / UI_VERTEX_BYTES` lands on a whole vertex (otherwise + // the shader reads partway through the previous vertex and the + // text quads end up reading garbled UVs/colors). upload_size is + // already vertex_count × UI_VERTEX_BYTES so the running offset + // stays vertex-aligned without an extra `% UI_VERTEX_BYTES` pad. self.mtl_buf_offset = byte_off + upload_size; - // Align next slice to 16B for safety with packed_float4 reads. - align : s64 = 16; - rem := self.mtl_buf_offset % align; - if rem != 0 { self.mtl_buf_offset = self.mtl_buf_offset + (align - rem); } } else { // Only re-bind the current texture (program, projection, VAO // already bound in begin()). glBufferData orphans the old buffer @@ -332,12 +334,20 @@ UIRenderer :: struct { font := g_font; if font == null { return; } - // Shape text into positioned glyphs + // Shape text into positioned glyphs. This may rasterize new glyphs + // AND grow the atlas, which creates a new GPU texture under + // `font.texture_id`. Re-bind the font atlas after shaping so the + // upcoming text quads sample the texture that actually holds the + // glyphs we just rasterized. font.shape_text(node.text, node.font_size); - // Flush any new glyphs to the atlas texture (no texture switch needed — atlas is already bound) + // Flush any new glyphs to the atlas texture font.flush(); + // If the atlas grew (or otherwise changed handle), switch the + // current texture so the next flush samples the right one. + self.bind_texture(font.texture_id); + r := node.text_color.rf(); g := node.text_color.gf(); b := node.text_color.bf(); @@ -609,11 +619,14 @@ fragment float4 fmain(VOut in [[stage_in]], // Image mode (mode == -2.0): sample texture return tex.sample(s, in.uv) * in.color; } else if (mode < 0.0) { - // Text mode (mode == -1.0): sample glyph atlas .r as alpha + // Text mode (mode == -1.0): the glyph atlas stores R8 alpha + // coverage from stbtt_MakeGlyphBitmap. Use the sampled value + // directly as alpha (no smoothstep — those were for SDFs and + // thinned anti-aliased coverage strokes). Small-size text renders + // dim on dark backgrounds because most glyph pixels sit in 0.1-0.5 + // coverage; tracked as the "faint text" follow-up. float alpha = tex.sample(s, in.uv).r; - float ew = fwidth(alpha) * 0.7; - alpha = smoothstep(0.5 - ew, 0.5 + ew, alpha); - return float4(in.color.rgb, in.color.a * pow(alpha, 0.9)); + return float4(in.color.rgb, in.color.a * alpha); } else if (mode > 0.0 || border > 0.0) { // Rounded rect: SDF alpha, vertex color only float2 half_size = rectSize * 0.5;