acdream/src/AcDream.App/UI/UiDatFont.cs
Erik 828bec5fb5 @
fix(D.2b): point-sample the dat-font atlas so UI text is pixel-crisp

The font glyph atlas was uploaded with bilinear (Linear) min/mag filtering, which
softens the small dat-font glyphs (the menu/button text "blur"). Add a nearest-filter
path to UploadRgba8/GetOrUploadRenderSurface and use it for the font atlases only
(world + other UI surfaces keep bilinear). Combined with the existing per-glyph
pixel-snap, glyph texels now map 1:1 to screen pixels. Sharpens all dat-font text
(transcript, menu, Send/Chat buttons, vitals numbers).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-06-16 12:02:07 +02:00

162 lines
7.2 KiB
C#

using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
namespace AcDream.App.UI;
/// <summary>
/// A retail dat-font (DB_TYPE_FONT, id range 0x40000000-0x40000FFF) ready for
/// 2D drawing. Holds the two GL atlas textures (foreground glyph pixels +
/// background outline/shadow), the per-glyph descriptor table, and the line
/// metrics, so <see cref="UiRenderContext.DrawStringDat"/> can blit each glyph
/// as two textured quads exactly the way the retail client does.
///
/// <para>
/// Retail render model — <c>SurfaceWindow::DrawCharacter</c>
/// (acclient 0x00442bd0, Font::GetCharDesc + the two SurfaceWindow blits): for
/// each glyph it copies the BACKGROUND atlas sub-rect first, tinted with the
/// outline color (black), then the FOREGROUND atlas sub-rect, tinted with the
/// requested text color. The pen advances by
/// <c>HorizontalOffsetBefore + Width + HorizontalOffsetAfter</c> (the function's
/// return value, accumulated by the string loop at 0x00467ed4
/// <c>edi_3 += var_98</c>), and each glyph is drawn starting at
/// <c>penX + HorizontalOffsetBefore</c>.
/// </para>
///
/// <para>
/// Atlas format: the foreground atlas (0x06005EE5 for Font 0x40000000) is
/// PFID_A8 — alpha-only. Our <c>SurfaceDecoder</c> expands A8 to RGBA as
/// (255,255,255, alpha). The UI sprite shader path (ui_text.frag,
/// <c>uUseTexture==2</c>) MULTIPLIES the sampled texel by the per-vertex tint
/// (<c>texture(uTex,vUv) * vColor</c>), so tinting a white+alpha glyph by a
/// color gives that color with the glyph's alpha — black for the outline pass,
/// text color for the fill pass. No shader change was needed.
/// </para>
/// </summary>
public sealed class UiDatFont
{
/// <summary>Retail UI font id (Latin-1, 16x16 max, with outline atlas).</summary>
public const uint DefaultFontId = 0x40000000u;
/// <summary>Foreground (glyph pixels) GL texture handle + atlas pixel size.</summary>
public uint ForegroundTexture { get; }
public int ForegroundWidth { get; }
public int ForegroundHeight { get; }
/// <summary>Background (outline/shadow) GL texture handle + atlas pixel size.
/// 0 when the font has no background atlas (then the outline pass is skipped).</summary>
public uint BackgroundTexture { get; }
public int BackgroundWidth { get; }
public int BackgroundHeight { get; }
/// <summary>Vertical advance between lines (retail MaxCharHeight).</summary>
public float LineHeight { get; }
/// <summary>Distance from a line's top to its baseline (retail BaselineOffset).</summary>
public float BaselineOffset { get; }
private readonly Dictionary<char, FontCharDesc> _glyphs;
private UiDatFont(
uint fgTex, int fgW, int fgH,
uint bgTex, int bgW, int bgH,
float lineHeight, float baselineOffset,
Dictionary<char, FontCharDesc> glyphs)
{
ForegroundTexture = fgTex; ForegroundWidth = fgW; ForegroundHeight = fgH;
BackgroundTexture = bgTex; BackgroundWidth = bgW; BackgroundHeight = bgH;
LineHeight = lineHeight;
BaselineOffset = baselineOffset;
_glyphs = glyphs;
}
/// <summary>True if this font carries a separate outline/shadow atlas
/// (retail's <c>m_pBackgroundSurface</c>). When false the outline pass is
/// skipped and only the foreground (fill) glyphs are drawn.</summary>
public bool HasBackground => BackgroundTexture != 0;
/// <summary>Look up a glyph descriptor for a character. Returns false for
/// characters not present in the font's table (callers skip them).</summary>
public bool TryGetGlyph(char c, out FontCharDesc glyph) => _glyphs.TryGetValue(c, out glyph!);
/// <summary>
/// Load Font <paramref name="fontId"/> from the dat collection and upload
/// both atlases through the texture cache (the same direct-RenderSurface
/// path the D.2b chrome sprites use). Returns null if the Font DBObj is
/// missing — callers fall back to the debug bitmap font.
/// </summary>
public static UiDatFont? Load(DatCollection dats, TextureCache cache, uint fontId = DefaultFontId)
{
ArgumentNullException.ThrowIfNull(dats);
ArgumentNullException.ThrowIfNull(cache);
if (!dats.TryGet<Font>(fontId, out var font) || font is null)
return null;
// Foreground atlas is required; without it there are no glyph pixels.
if (font.ForegroundSurfaceDataId == 0)
return null;
// Point-sample the glyph atlases (nearest) so small UI text stays pixel-crisp;
// bilinear softens the dat font noticeably (the chat menu/button text "blur").
uint fgTex = cache.GetOrUploadRenderSurface(font.ForegroundSurfaceDataId, out int fgW, out int fgH, nearest: true);
uint bgTex = 0; int bgW = 0, bgH = 0;
if (font.BackgroundSurfaceDataId != 0)
bgTex = cache.GetOrUploadRenderSurface(font.BackgroundSurfaceDataId, out bgW, out bgH, nearest: true);
// Build the char->descriptor lookup. FontCharDesc.Unicode is the code
// point; for Latin-1 fonts this is a direct char cast. Last write wins
// on the rare duplicate (retail's Font::GetCharDesc does a linear scan
// and returns the first match, but the dat tables have no duplicates).
var glyphs = new Dictionary<char, FontCharDesc>(font.CharDescs.Count);
foreach (var cd in font.CharDescs)
glyphs[(char)cd.Unicode] = cd;
return new UiDatFont(
fgTex, fgW, fgH,
bgTex, bgW, bgH,
lineHeight: font.MaxCharHeight,
baselineOffset: font.BaselineOffset,
glyphs);
}
/// <summary>
/// Total pen advance (in pixels) for <paramref name="text"/>, summing each
/// glyph's retail advance. Characters not in the font contribute nothing.
/// </summary>
public float MeasureWidth(string text)
=> MeasureWidth(text, c => _glyphs.TryGetValue(c, out var g) ? g : null);
/// <summary>
/// Pure pen-advance summation seam: total width of <paramref name="text"/>
/// given a <paramref name="lookup"/> that maps each char to its descriptor
/// (null = not in the font → contributes nothing). Lets the advance math be
/// unit-tested with synthetic glyphs, with no GL or dat dependency.
/// </summary>
public static float MeasureWidth(string? text, Func<char, FontCharDesc?> lookup)
{
ArgumentNullException.ThrowIfNull(lookup);
if (string.IsNullOrEmpty(text)) return 0f;
float w = 0f;
for (int i = 0; i < text.Length; i++)
if (lookup(text[i]) is { } g)
w += GlyphAdvance(g);
return w;
}
/// <summary>
/// The retail per-glyph horizontal advance:
/// <c>HorizontalOffsetBefore + Width + HorizontalOffsetAfter</c>. This is the
/// value <c>SurfaceWindow::DrawCharacter</c> returns for proportional text
/// (flag bit 0x10 set, acclient 0x00442c3a) and the string loop accumulates
/// into the pen. Pulled out as a pure static so the math is unit-testable
/// without GL or the dat.
/// </summary>
public static float GlyphAdvance(FontCharDesc g)
=> g.HorizontalOffsetBefore + g.Width + g.HorizontalOffsetAfter;
}