acdream/src/AcDream.App/Rendering/BitmapFont.cs
Erik ff325abd7b feat(ui): debug overlay + refined input controls
Adds the first on-screen HUD for the dev client plus today's mouse-control
refinements. Also lands yesterday's scenery-alignment changes that were
left uncommitted in the working tree.

Overlay:
- BitmapFont rasterizes a system TTF via StbTrueTypeSharp into a 512x512
  R8 atlas at startup (Consolas on Windows, DejaVu/Menlo fallbacks)
- TextRenderer batches 2D quads in screen-space with ortho projection;
  one shader + two draw calls (rect then text) for panel backgrounds
  under glyphs
- DebugOverlay composes info / stats / compass / help panels on top of
  the 3D scene; toggles via F1/F4/F5/F6; transient toasts for key events
- DebugLineRenderer and its shaders (carried over from the scenery work)
  are properly committed in this commit

Controls:
- Per-mode mouse sensitivity (Chase 0.15, Fly 1.0, Orbit 1.0); F8/F9 to
  adjust the active mode multiplicatively (x1.2)
- Hold RMB to free-orbit the chase camera around the player; release
  stays at the new angle (no snap-back)
- Mouse-wheel zooms chase distance between 2m and 40m
- Chase pitch widened to [-0.7, 1.4] so mouse-Y tilts both ways from
  the default neutral angle

Scenery alignment (carried from yesterday's session):
- ShadowObjectRegistry AllEntriesForDebug + Scale field
- SceneryGenerator uses ACViewer's OnRoad polygon test + baseLoc +
  set_heading rotation
- BSPQuery dispatchers accept localToWorld so normals/offsets transform
  correctly per part
- TransitionTypes.CylinderCollision rewritten with wall-slide + push-out
- PhysicsDataCache caches visual-mesh AABB for scenery that lacks
  physics Setup bounds
2026-04-17 18:45:38 +02:00

180 lines
6.2 KiB
C#

using System;
using System.IO;
using Silk.NET.OpenGL;
using StbTrueTypeSharp;
namespace AcDream.App.Rendering;
/// <summary>
/// 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
/// <see cref="TryGetGlyph"/> to resolve an ASCII codepoint to UV + metrics.
///
/// Only printable ASCII (32..127) is supported for the debug overlay.
/// </summary>
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;
}
/// <summary>Measure the pixel width of a single-line string in this font.</summary>
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);
}
/// <summary>
/// Try to load a monospaced system font from well-known paths on the host OS.
/// Returns null if no candidate was found.
/// </summary>
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;
}
}