engine: add deterministic glyph mask core
This commit is contained in:
135
engine/Age.Engine/Text/AgeGlyphMaskCompositor.cs
Normal file
135
engine/Age.Engine/Text/AgeGlyphMaskCompositor.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
120
engine/Age.Engine/Text/BoundedLruCache.cs
Normal file
120
engine/Age.Engine/Text/BoundedLruCache.cs
Normal 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();
|
||||
}
|
||||
112
engine/Age.Engine/Text/GlyphMaskContracts.cs
Normal file
112
engine/Age.Engine/Text/GlyphMaskContracts.cs
Normal 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);
|
||||
}
|
||||
91
engine/Age.Engine/Text/RetainedGlyphLayoutEngine.cs
Normal file
91
engine/Age.Engine/Text/RetainedGlyphLayoutEngine.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user