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;
}
}