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> @
162 lines
7.2 KiB
C#
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;
|
|
}
|