engine: add deterministic glyph mask core

This commit is contained in:
gamer147
2026-07-30 18:25:46 -04:00
parent 41001f6f77
commit 7397e4e5fb
9 changed files with 818 additions and 8 deletions

View File

@@ -0,0 +1,135 @@
using Age.Engine.Model;
using Age.Engine.Sys4;
namespace Age.Engine.Text;
/// <summary>AGE's deterministic 32-bit glyph-mask compositor, independent of mask provenance.</summary>
public static class AgeGlyphMaskCompositor
{
public static void DrawGlyph(
RgbaImage destination,
GlyphMask mask,
int cellX,
int cellY,
int topToBaseline,
AdvTextStyle style)
{
ArgumentNullException.ThrowIfNull(destination);
ArgumentNullException.ThrowIfNull(mask);
ValidateDestination(destination);
int baselineY = checked(cellY + topToBaseline);
if (style.RenderMode == 1)
DrawMask(destination, mask,
checked(cellX + style.EffectOffsetX),
checked(baselineY + style.EffectOffsetY),
style.EffectColor);
else if (style.RenderMode == 3)
foreach ((int x, int y) in GetMode3OutlineOffsets(
style.EffectOffsetX, style.EffectOffsetY))
DrawMask(destination, mask, checked(cellX + x), checked(baselineY + y),
style.EffectColor);
DrawMask(destination, mask, cellX, baselineY, style.TextColor);
if (style.RenderMode == 2)
DrawMask(destination, mask, cellX, baselineY, style.TextColor, coverageShift: 2);
}
/// <summary>
/// Draw at a GDI-style baseline. mask.OriginX moves right from the pen and mask.OriginY moves
/// upward from the baseline. coverageShift=2 reproduces AGE's mode-2 quarter-coverage pass.
/// </summary>
public static bool DrawMask(
RgbaImage destination,
GlyphMask mask,
int baselineX,
int baselineY,
long rgb,
int coverageShift = 0)
{
ArgumentNullException.ThrowIfNull(destination);
ArgumentNullException.ThrowIfNull(mask);
ValidateDestination(destination);
if (coverageShift is < 0 or > 4)
throw new ArgumentOutOfRangeException(nameof(coverageShift));
int red = (int)((rgb >> 16) & 0xff);
int green = (int)((rgb >> 8) & 0xff);
int blue = (int)(rgb & 0xff);
int left = checked(baselineX + mask.OriginX);
int top = checked(baselineY - mask.OriginY);
ReadOnlySpan<byte> coverage = mask.Coverage.Span;
bool changed = false;
for (int row = 0; row < mask.Height; row++)
{
int y = checked(top + row);
if ((uint)y >= (uint)destination.Height) continue;
int sourceRow = row * mask.Stride;
for (int column = 0; column < mask.Width; column++)
{
int x = checked(left + column);
if ((uint)x >= (uint)destination.Width) continue;
int level = coverage[sourceRow + column] >> coverageShift;
if (level == 0) continue;
int alpha = level * 255 / 16;
BlendTextPixel(destination.Pixels,
checked((y * destination.Width + x) * 4),
red, green, blue, alpha);
changed = true;
}
}
return changed;
}
public static IReadOnlyList<(int X, int Y)> GetMode3OutlineOffsets(int radiusX, int radiusY)
{
double radius = Math.Sqrt((double)radiusX * radiusX + (double)radiusY * radiusY);
if (radius == 0) return Array.Empty<(int, int)>();
int sampleCount = checked((int)Math.Ceiling(radius * 8.0));
var result = new (int X, int Y)[sampleCount];
double step = 360.0 / (radius * 8.0);
for (int index = 0; index < result.Length; index++)
{
double radians = index * step * Math.PI / 180.0;
result[index] = (
RoundNearest(Math.Cos(radians) * radiusX),
RoundNearest(Math.Sin(radians) * radiusY));
}
return result;
}
private static int RoundNearest(double value)
=> checked((int)(value < 0 ? value - 0.5 : value + 0.5));
private static void BlendTextPixel(
byte[] destination, int offset, int red, int green, int blue, int alpha)
{
int destinationAlpha = destination[offset + 3];
if (alpha == 255 || destinationAlpha == 0)
{
destination[offset] = (byte)red;
destination[offset + 1] = (byte)green;
destination[offset + 2] = (byte)blue;
destination[offset + 3] = (byte)alpha;
return;
}
int inverse = 255 - alpha;
destination[offset] =
(byte)((destination[offset] * inverse + red * alpha) / 255);
destination[offset + 1] =
(byte)((destination[offset + 1] * inverse + green * alpha) / 255);
destination[offset + 2] =
(byte)((destination[offset + 2] * inverse + blue * alpha) / 255);
destination[offset + 3] = (byte)Math.Max(destinationAlpha, alpha);
}
private static void ValidateDestination(RgbaImage destination)
{
if (destination.Width <= 0 || destination.Height <= 0
|| destination.Pixels.Length != checked(destination.Width * destination.Height * 4))
throw new ArgumentException("Destination must be a non-empty tightly packed RGBA image.",
nameof(destination));
}
}

View File

@@ -0,0 +1,120 @@
namespace Age.Engine.Text;
/// <summary>A small thread-safe LRU used by glyph masks and future backend-owned font handles.</summary>
public sealed class BoundedLruCache<TKey, TValue> where TKey : notnull
{
private sealed record Entry(TKey Key, TValue Value);
private readonly object _gate = new();
private readonly int _capacity;
private readonly Action<TValue>? _onEvicted;
private readonly Dictionary<TKey, LinkedListNode<Entry>> _entries = new();
private readonly LinkedList<Entry> _recency = new();
public BoundedLruCache(int capacity, Action<TValue>? onEvicted = null)
{
if (capacity <= 0) throw new ArgumentOutOfRangeException(nameof(capacity));
_capacity = capacity;
_onEvicted = onEvicted;
}
public int Capacity => _capacity;
public int Count
{
get { lock (_gate) return _entries.Count; }
}
public bool TryGetValue(TKey key, out TValue value)
{
lock (_gate)
{
if (!_entries.TryGetValue(key, out LinkedListNode<Entry>? node))
{
value = default!;
return false;
}
_recency.Remove(node);
_recency.AddFirst(node);
value = node.Value.Value;
return true;
}
}
public void Set(TKey key, TValue value)
{
List<TValue>? evicted = null;
lock (_gate)
{
if (_entries.Remove(key, out LinkedListNode<Entry>? existing))
{
_recency.Remove(existing);
(evicted ??= new List<TValue>(2)).Add(existing.Value.Value);
}
var node = new LinkedListNode<Entry>(new Entry(key, value));
_recency.AddFirst(node);
_entries.Add(key, node);
if (_entries.Count > _capacity)
{
LinkedListNode<Entry> oldest = _recency.Last!;
_recency.RemoveLast();
_entries.Remove(oldest.Value.Key);
(evicted ??= new List<TValue>(1)).Add(oldest.Value.Value);
}
}
if (_onEvicted != null && evicted != null)
foreach (TValue removed in evicted) _onEvicted(removed);
}
public void Clear()
{
TValue[] removed;
lock (_gate)
{
removed = _recency.Select(entry => entry.Value).ToArray();
_entries.Clear();
_recency.Clear();
}
if (_onEvicted != null)
foreach (TValue value in removed) _onEvicted(value);
}
}
/// <summary>Bounded caching decorator shared by exact and portable glyph-mask backends.</summary>
public sealed class CachedGlyphMaskRasterizer : IGlyphMaskRasterizer
{
private readonly IGlyphMaskRasterizer _inner;
private readonly BoundedLruCache<GlyphRasterRequest, GlyphMask> _cache;
private long _hits;
private long _misses;
public CachedGlyphMaskRasterizer(IGlyphMaskRasterizer inner, int capacity)
{
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
_cache = new BoundedLruCache<GlyphRasterRequest, GlyphMask>(capacity);
}
public int Count => _cache.Count;
public int Capacity => _cache.Capacity;
public long Hits => Interlocked.Read(ref _hits);
public long Misses => Interlocked.Read(ref _misses);
public GlyphMask Rasterize(GlyphRasterRequest request)
{
ArgumentNullException.ThrowIfNull(request);
if (_cache.TryGetValue(request, out GlyphMask? cached))
{
Interlocked.Increment(ref _hits);
return cached;
}
GlyphMask result = _inner.Rasterize(request)
?? throw new InvalidOperationException("The glyph rasterizer returned null.");
_cache.Set(request, result);
Interlocked.Increment(ref _misses);
return result;
}
public void Clear() => _cache.Clear();
}

View File

@@ -0,0 +1,112 @@
using System.Text;
namespace Age.Engine.Text;
public enum GlyphRasterPolicy
{
NativeCp932Gray4,
PortableUnicode,
}
/// <summary>
/// Backend-neutral description of one requested glyph. Native-compatible requests preserve both
/// the Unicode scalar used by authored code and the original CP932 code consumed by AGE/GDI.
/// </summary>
public sealed record GlyphRasterRequest
{
public GlyphRasterRequest(
string fontFace,
int pixelHeight,
int requestedWidth,
int weight,
int unicodeScalar,
ushort? cp932Code,
GlyphRasterPolicy policy)
{
ArgumentException.ThrowIfNullOrWhiteSpace(fontFace);
if (pixelHeight <= 0) throw new ArgumentOutOfRangeException(nameof(pixelHeight));
if (weight is < 0 or > 1000) throw new ArgumentOutOfRangeException(nameof(weight));
if (!Rune.IsValid(unicodeScalar)) throw new ArgumentOutOfRangeException(nameof(unicodeScalar));
if (policy == GlyphRasterPolicy.NativeCp932Gray4 && cp932Code == null)
throw new ArgumentException("Native CP932 glyph requests require the original code.", nameof(cp932Code));
FontFace = fontFace;
PixelHeight = pixelHeight;
RequestedWidth = requestedWidth;
Weight = weight;
UnicodeScalar = unicodeScalar;
Cp932Code = cp932Code;
Policy = policy;
}
public string FontFace { get; }
public int PixelHeight { get; }
public int RequestedWidth { get; }
public int Weight { get; }
public int UnicodeScalar { get; }
public ushort? Cp932Code { get; }
public GlyphRasterPolicy Policy { get; }
}
/// <summary>
/// One grayscale glyph mask plus GDI-compatible placement and cell metrics. Coverage values use
/// AGE's normalized 0..16 range, regardless of which platform rasterizer produced them.
/// </summary>
public sealed class GlyphMask
{
private readonly byte[] _coverage;
public GlyphMask(
int width,
int height,
int stride,
int originX,
int originY,
int cellAdvanceX,
int cellAdvanceY,
int cellWidth,
int cellHeight,
ReadOnlySpan<byte> coverage)
{
if (width < 0) throw new ArgumentOutOfRangeException(nameof(width));
if (height < 0) throw new ArgumentOutOfRangeException(nameof(height));
if (stride < width) throw new ArgumentOutOfRangeException(nameof(stride));
if (cellWidth < 0) throw new ArgumentOutOfRangeException(nameof(cellWidth));
if (cellHeight < 0) throw new ArgumentOutOfRangeException(nameof(cellHeight));
int required = checked(stride * height);
if (coverage.Length != required)
throw new ArgumentException($"Glyph coverage has {coverage.Length} bytes; expected {required}.",
nameof(coverage));
foreach (byte value in coverage)
if (value > 16)
throw new ArgumentException("Glyph coverage values must be in AGE's 0..16 range.",
nameof(coverage));
Width = width;
Height = height;
Stride = stride;
OriginX = originX;
OriginY = originY;
CellAdvanceX = cellAdvanceX;
CellAdvanceY = cellAdvanceY;
CellWidth = cellWidth;
CellHeight = cellHeight;
_coverage = coverage.ToArray();
}
public int Width { get; }
public int Height { get; }
public int Stride { get; }
public int OriginX { get; }
public int OriginY { get; }
public int CellAdvanceX { get; }
public int CellAdvanceY { get; }
public int CellWidth { get; }
public int CellHeight { get; }
public ReadOnlyMemory<byte> Coverage => _coverage;
}
public interface IGlyphMaskRasterizer
{
GlyphMask Rasterize(GlyphRasterRequest request);
}

View File

@@ -0,0 +1,91 @@
using Age.Engine.Model;
using Age.Engine.Sys4;
namespace Age.Engine.Text;
public readonly record struct GlyphTextLayoutOptions(
int CursorX,
int CursorY,
int LineOriginX,
int RightBound,
int BottomBound,
bool WrapHorizontally,
AdvTextStyle Style);
public sealed record GlyphTextLayoutResult(
IReadOnlyList<AdvRetainedGlyphRecord> Records,
int CursorX,
int CursorY,
int ConsumedGlyphs,
int WrappedLines,
AdvTextOverflowFlags ObservedOverflow,
bool StoppedOnVerticalOverflow);
/// <summary>
/// Builds native-edge retained records while rasterizing masks into the layout's RGBA surface.
/// This is deliberately presentation-neutral: binding records to GfxState belongs to a later slice.
/// </summary>
public sealed class RetainedGlyphLayoutEngine
{
private readonly IGlyphMaskRasterizer _rasterizer;
public RetainedGlyphLayoutEngine(IGlyphMaskRasterizer rasterizer)
=> _rasterizer = rasterizer ?? throw new ArgumentNullException(nameof(rasterizer));
public GlyphTextLayoutResult Render(
RgbaImage destination,
GlyphTextLayoutOptions options,
IReadOnlyList<GlyphRasterRequest> glyphs)
{
ArgumentNullException.ThrowIfNull(destination);
ArgumentNullException.ThrowIfNull(glyphs);
var records = new List<AdvRetainedGlyphRecord>(glyphs.Count);
int cursorX = options.CursorX;
int cursorY = options.CursorY;
int wrappedLines = 0;
var observed = AdvTextOverflowFlags.None;
bool stopped = false;
foreach (GlyphRasterRequest request in glyphs)
{
ArgumentNullException.ThrowIfNull(request);
GlyphMask mask = _rasterizer.Rasterize(request);
int right = checked(cursorX + mask.CellWidth);
int bottom = checked(cursorY + mask.CellHeight);
AdvTextOverflowFlags overflow = AdvRetainedTextContract.CheckOverflow(
options.RightBound, options.BottomBound, right, bottom);
observed |= overflow;
if (overflow == AdvTextOverflowFlags.Vertical)
{
stopped = true;
break;
}
if (overflow.HasFlag(AdvTextOverflowFlags.Horizontal)
&& options.WrapHorizontally
&& (request.Cp932Code == null
|| !AdvRetainedTextContract.PreventsHorizontalWrapBefore(
request.Cp932Code.Value)))
{
int fontHeight = options.Style.PrimaryFontSize > 0
? options.Style.PrimaryFontSize
: request.PixelHeight;
cursorX = options.LineOriginX;
cursorY = checked(cursorY + fontHeight + options.Style.LineSpacing);
wrappedLines++;
right = checked(cursorX + mask.CellWidth);
bottom = checked(cursorY + mask.CellHeight);
}
AgeGlyphMaskCompositor.DrawGlyph(
destination, mask, cursorX, cursorY, request.PixelHeight, options.Style);
records.Add(new AdvRetainedGlyphRecord(0, cursorX, cursorY, right, bottom));
cursorX = checked(cursorX + mask.CellAdvanceX);
cursorY = checked(cursorY + mask.CellAdvanceY);
}
return new GlyphTextLayoutResult(
records, cursorX, cursorY, records.Count, wrappedLines, observed, stopped);
}
}