using System; using System.IO; using Silk.NET.OpenGL; using StbTrueTypeSharp; namespace AcDream.App.Rendering; /// /// A pixel-font atlas rasterized from a TTF at load time using stb_truetype. /// Glyphs are packed into a single-channel (R8) GL texture. Call /// to resolve an ASCII codepoint to UV + metrics. /// /// Only printable ASCII (32..127) is supported for the debug overlay. /// public sealed unsafe class BitmapFont : IDisposable { public readonly struct Glyph { public readonly float UvMinX; public readonly float UvMinY; public readonly float UvMaxX; public readonly float UvMaxY; public readonly float OffsetX; // from cursor to glyph quad top-left public readonly float OffsetY; public readonly float Width; // pixels public readonly float Height; public readonly float Advance; public Glyph(float umn, float vmn, float umx, float vmx, float ox, float oy, float w, float h, float adv) { UvMinX = umn; UvMinY = vmn; UvMaxX = umx; UvMaxY = vmx; OffsetX = ox; OffsetY = oy; Width = w; Height = h; Advance = adv; } } private readonly GL _gl; private readonly Glyph[] _glyphs; private readonly int _firstChar; private readonly int _numChars; public uint TextureId { get; } public float PixelHeight { get; } public float LineHeight { get; } public float Ascent { get; } public int AtlasWidth { get; } public int AtlasHeight { get; } public BitmapFont(GL gl, byte[] ttfBytes, float pixelHeight, int atlasSize = 512, int firstChar = 32, int numChars = 96) { _gl = gl; PixelHeight = pixelHeight; AtlasWidth = atlasSize; AtlasHeight = atlasSize; _firstChar = firstChar; _numChars = numChars; // Bake the glyph bitmap via stbtt_BakeFontBitmap. var bakedChars = new StbTrueType.stbtt_bakedchar[numChars]; var pixels = new byte[AtlasWidth * AtlasHeight]; bool ok = StbTrueType.stbtt_BakeFontBitmap( ttfBytes, 0, pixelHeight, pixels, AtlasWidth, AtlasHeight, firstChar, numChars, bakedChars); if (!ok) throw new InvalidOperationException( $"stbtt_BakeFontBitmap failed: atlas {atlasSize}x{atlasSize} " + $"too small for pixelHeight={pixelHeight}"); // Extract vertical metrics for line spacing. using var info = StbTrueType.CreateFont(ttfBytes, 0) ?? throw new InvalidOperationException("stbtt_InitFont failed"); float scale = StbTrueType.stbtt_ScaleForPixelHeight(info, pixelHeight); int ascent, descent, lineGap; StbTrueType.stbtt_GetFontVMetrics(info, &ascent, &descent, &lineGap); Ascent = ascent * scale; LineHeight = (ascent - descent + lineGap) * scale; // Convert baked-char records to our Glyph struct. _glyphs = new Glyph[numChars]; for (int i = 0; i < numChars; i++) { var bc = bakedChars[i]; float w = bc.x1 - bc.x0; float h = bc.y1 - bc.y0; _glyphs[i] = new Glyph( umn: bc.x0 / (float)AtlasWidth, vmn: bc.y0 / (float)AtlasHeight, umx: bc.x1 / (float)AtlasWidth, vmx: bc.y1 / (float)AtlasHeight, ox: bc.xoff, oy: bc.yoff, w: w, h: h, adv: bc.xadvance); } // Upload atlas as a single-channel GL texture (R8). TextureId = _gl.GenTexture(); _gl.BindTexture(TextureTarget.Texture2D, TextureId); _gl.PixelStore(PixelStoreParameter.UnpackAlignment, 1); fixed (byte* ptr = pixels) { _gl.TexImage2D(TextureTarget.Texture2D, 0, (int)InternalFormat.R8, (uint)AtlasWidth, (uint)AtlasHeight, 0, PixelFormat.Red, PixelType.UnsignedByte, ptr); } _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear); _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear); _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, (int)TextureWrapMode.ClampToEdge); _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, (int)TextureWrapMode.ClampToEdge); _gl.PixelStore(PixelStoreParameter.UnpackAlignment, 4); // restore default _gl.BindTexture(TextureTarget.Texture2D, 0); } public bool TryGetGlyph(char c, out Glyph g) { int idx = c - _firstChar; if ((uint)idx >= (uint)_numChars) { g = default; return false; } g = _glyphs[idx]; return true; } /// Measure the pixel width of a single-line string in this font. public float MeasureWidth(string s) { float w = 0; for (int i = 0; i < s.Length; i++) { if (TryGetGlyph(s[i], out var g)) w += g.Advance; } return w; } public void Dispose() { _gl.DeleteTexture(TextureId); } /// /// Try to load a monospaced system font from well-known paths on the host OS. /// Returns null if no candidate was found. /// public static byte[]? TryLoadSystemMonospaceFont() { string[] candidates = { @"C:\Windows\Fonts\consola.ttf", @"C:\Windows\Fonts\cour.ttf", @"C:\Windows\Fonts\arial.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", "/usr/share/fonts/TTF/DejaVuSansMono.ttf", "/Library/Fonts/Menlo.ttc", "/System/Library/Fonts/Menlo.ttc", }; foreach (var path in candidates) { try { if (File.Exists(path)) return File.ReadAllBytes(path); } catch { // try next candidate } } return null; } }