using System; using System.IO; using AcDream.App.Rendering.Gpu; 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) atlas. Call /// to resolve an ASCII codepoint to UV + metrics. /// /// Campaign V slice V4a: the atlas is created and uploaded through /// instead of raw GL, and registered /// into the device's texture table. /// /// Campaign V slice V6d: is that registration's /// rather than the raw GL name it used to /// be. Its only consumer is , which now samples the /// table instead of binding a texture unit, so the GL name has no remaining /// reader — and on Vulkan there is no GL name to have. /// /// 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 Glyph[] _glyphs; private readonly int _firstChar; private readonly int _numChars; private readonly IGpuTexture _texture; /// /// The atlas's — a one-based index into /// the device's global texture table, not a GL texture name. The name is /// unchanged so that the public shape of this class did not move for a /// change of currency its only consumer makes invisible. /// public uint TextureId { get; } public float PixelHeight { get; } public float LineHeight { get; } public float Ascent { get; } public int AtlasWidth { get; } public int AtlasHeight { get; } // internal, not public: IGpuDevice is an internal type (the pinned RHI // contract). BitmapFont stays public — only construction is restricted — // so existing public members that hold or return a BitmapFont need no // visibility change of their own. internal BitmapFont(IGpuDevice device, byte[] ttfBytes, float pixelHeight, int atlasSize = 512, int firstChar = 32, int numChars = 96) { ArgumentNullException.ThrowIfNull(device); 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 texture (R8) through the device. IGpuTexture texture = device.CreateTexture(new GpuTextureDescription( "bitmap-font-atlas", GpuTextureKind.Texture2D, GpuTextureFormat.R8Unorm, Width: AtlasWidth, Height: AtlasHeight, LayerCount: 1, MipLevelCount: 1)); GpuTextureSlot slot; try { fixed (byte* ptr = pixels) texture.Upload(0, 0, new ReadOnlySpan(ptr, AtlasWidth * AtlasHeight)); // Clamped, so a glyph's edge texel cannot bleed in from the opposite // side of the atlas. Retail's dat font glyph UVs never leave their // own tight sub-rect, but the sampler states it rather than relying // on that. Slice V6d: this sampler is now the only thing that // decides how the atlas is filtered — the raw glTexParameter pass // that used to sit here existed solely because the classic // texture-unit path sampled the texture object directly, and it is // gone with that path. IGpuSampler sampler = device.CreateSampler(GpuSamplerDescription.WorldClamp); slot = device.RegisterTexture(texture, sampler); } catch { texture.Dispose(); throw; } _texture = texture; TextureId = UiTextureTableHandle.FromSlot(slot); } 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() { _texture.Dispose(); } /// /// 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; } }