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,321 @@
using Age.Engine.Model;
using Age.Engine.Sys4;
using Age.Engine.Text;
public class GlyphMaskCompositorTests
{
private sealed class CountingRasterizer(Func<GlyphRasterRequest, GlyphMask> factory)
: IGlyphMaskRasterizer
{
public int Calls { get; private set; }
public GlyphMask Rasterize(GlyphRasterRequest request)
{
Calls++;
return factory(request);
}
}
[Fact]
public void NativeRequestRequiresCp932IdentityAndValidScalar()
{
Assert.Throws<ArgumentException>(() => new GlyphRasterRequest(
"face", 24, -12, 700, 0x3042, null, GlyphRasterPolicy.NativeCp932Gray4));
Assert.Throws<ArgumentOutOfRangeException>(() => new GlyphRasterRequest(
"face", 24, -12, 700, 0x110000, 0x82a0, GlyphRasterPolicy.NativeCp932Gray4));
var request = new GlyphRasterRequest(
"face", 24, -12, 700, 0x3042, 0x82a0, GlyphRasterPolicy.NativeCp932Gray4);
Assert.Equal(("face", 24, -12, 700, 0x3042, (ushort)0x82a0),
(request.FontFace, request.PixelHeight, request.RequestedWidth, request.Weight,
request.UnicodeScalar, request.Cp932Code!.Value));
}
[Fact]
public void MaskOwnsValidatedNormalizedCoverage()
{
byte[] source = [0, 8, 16, 0];
var mask = new GlyphMask(2, 1, 4, 0, 1, 2, 0, 2, 3, source);
source[1] = 0;
Assert.Equal(new byte[] { 0, 8, 16, 0 }, mask.Coverage.ToArray());
Assert.Throws<ArgumentException>(
() => new GlyphMask(1, 1, 1, 0, 0, 1, 0, 1, 1, [17]));
Assert.Throws<ArgumentOutOfRangeException>(
() => new GlyphMask(2, 1, 1, 0, 0, 1, 0, 1, 1, [0]));
}
[Fact]
public void Gray4CoverageUsesNativeAlphaAndTransparentDestinationRule()
{
var destination = Image(1, 1);
GlyphMask mask = Mask(coverage: 8);
Assert.True(AgeGlyphMaskCompositor.DrawMask(
destination, mask, baselineX: 0, baselineY: 1, rgb: 0x123456));
Assert.Equal(new byte[] { 0x12, 0x34, 0x56, 127 }, destination.Pixels);
}
[Fact]
public void ExistingDestinationUsesIntegerRgbBlendAndMaximumAlpha()
{
var destination = new RgbaImage(1, 1, [10, 20, 30, 128]);
AgeGlyphMaskCompositor.DrawMask(
destination, Mask(coverage: 8), baselineX: 0, baselineY: 1, rgb: 0x6edc0a);
Assert.Equal(new byte[] { 59, 119, 20, 128 }, destination.Pixels);
}
[Fact]
public void BearingsAndDestinationBoundsClipTheMask()
{
var destination = Image(2, 1);
var mask = new GlyphMask(
3, 1, 4, originX: -1, originY: 1,
cellAdvanceX: 3, cellAdvanceY: 0, cellWidth: 3, cellHeight: 1,
[16, 16, 16, 0]);
AgeGlyphMaskCompositor.DrawMask(
destination, mask, baselineX: 0, baselineY: 1, rgb: 0x010203);
Assert.Equal(new byte[]
{
1, 2, 3, 255,
1, 2, 3, 255,
}, destination.Pixels);
}
[Fact]
public void ModeOneDrawsDisplacedEffectThenPrimaryMask()
{
var destination = Image(3, 1);
AdvTextStyle style = Style(renderMode: 1) with
{
TextColor = 0xff0000,
EffectColor = 0x0000ff,
EffectOffsetX = 1,
};
AgeGlyphMaskCompositor.DrawGlyph(
destination, Mask(), cellX: 0, cellY: 0, topToBaseline: 1, style);
Assert.Equal(new byte[]
{
255, 0, 0, 255,
0, 0, 255, 255,
0, 0, 0, 0,
}, destination.Pixels);
}
[Fact]
public void ModeTwoAddsQuarterCoveragePrimaryPass()
{
var destination = new RgbaImage(1, 1, [0, 0, 0, 255]);
AgeGlyphMaskCompositor.DrawGlyph(
destination, Mask(coverage: 8), 0, 0, 1,
Style(renderMode: 2) with { TextColor = 0xffffff });
Assert.Equal(new byte[] { 142, 142, 142, 255 }, destination.Pixels);
}
[Fact]
public void ModeThreeRadiusOneMatchesNativeTwelveSampleEllipse()
{
IReadOnlyList<(int X, int Y)> offsets =
AgeGlyphMaskCompositor.GetMode3OutlineOffsets(1, 1);
Assert.Equal(12, offsets.Count);
Assert.Equal(8, offsets.Distinct().Count());
Assert.Equal(2, offsets.Count(offset => offset == (1, 0)));
Assert.Equal(2, offsets.Count(offset => offset == (0, 1)));
Assert.Equal(2, offsets.Count(offset => offset == (-1, 0)));
Assert.Equal(2, offsets.Count(offset => offset == (0, -1)));
Assert.Equal(1, offsets.Count(offset => offset == (1, 1)));
Assert.Equal(1, offsets.Count(offset => offset == (-1, 1)));
Assert.Equal(1, offsets.Count(offset => offset == (-1, -1)));
Assert.Equal(1, offsets.Count(offset => offset == (1, -1)));
Assert.DoesNotContain((0, 0), offsets);
}
[Fact]
public void ModeThreeOverlappingMasksUseTheSameIntegerCompositorBeforePrimary()
{
var destination = Image(5, 3);
var mask = new GlyphMask(
2, 1, 2, originX: 0, originY: 1,
cellAdvanceX: 2, cellAdvanceY: 0, cellWidth: 2, cellHeight: 1,
[8, 16]);
AdvTextStyle style = Style(renderMode: 3) with
{
TextColor = 0xff0000,
EffectColor = 0x0000ff,
EffectOffsetX = 1,
EffectOffsetY = 1,
};
AgeGlyphMaskCompositor.DrawGlyph(
destination, mask, cellX: 2, cellY: 1, topToBaseline: 1, style);
// The left horizontal outline copy puts opaque blue under the half-coverage primary texel.
// AGE retains max alpha and blends RGB with integer truncation.
Assert.Equal((127, 0, 128, 255), Pixel(destination, 2, 1));
Assert.Equal((255, 0, 0, 255), Pixel(destination, 3, 1));
Assert.Equal((0, 0, 255, 127), Pixel(destination, 1, 1));
Assert.Equal((0, 0, 255, 255), Pixel(destination, 4, 1));
}
[Fact]
public void LayoutWrapsBeforeOrdinaryGlyphAndReturnsNativeEdgeRecords()
{
var destination = Image(16, 16);
var renderer = new CountingRasterizer(_ => CellMask());
var engine = new RetainedGlyphLayoutEngine(renderer);
GlyphTextLayoutResult result = engine.Render(
destination,
new GlyphTextLayoutOptions(
CursorX: 2, CursorY: 1, LineOriginX: 2,
RightBound: 8, BottomBound: 16, WrapHorizontally: true, Style()),
[NativeRequest(0x82a0, 0x3042), NativeRequest(0x82a2, 0x3044)]);
Assert.Equal(2, result.ConsumedGlyphs);
Assert.Equal(1, result.WrappedLines);
Assert.Equal((6, 7), (result.CursorX, result.CursorY));
Assert.Equal(AdvTextOverflowFlags.Horizontal, result.ObservedOverflow);
Assert.Collection(result.Records,
first => Assert.Equal(new AdvRetainedGlyphRecord(0, 2, 1, 6, 6), first),
second => Assert.Equal(new AdvRetainedGlyphRecord(0, 2, 7, 6, 12), second));
Assert.Equal(255, Pixel(destination, 2, 1).A);
Assert.Equal(255, Pixel(destination, 2, 7).A);
}
[Fact]
public void ClosingPunctuationStaysOnOverflowingPrecedingLine()
{
var engine = new RetainedGlyphLayoutEngine(new CountingRasterizer(_ => CellMask()));
GlyphTextLayoutResult result = engine.Render(
Image(16, 16),
new GlyphTextLayoutOptions(2, 1, 2, 8, 16, true, Style()),
[NativeRequest(0x82a0, 0x3042), NativeRequest(0x8141, 0x3001)]);
Assert.Equal(0, result.WrappedLines);
Assert.Equal((10, 1), (result.CursorX, result.CursorY));
Assert.Equal(new AdvRetainedGlyphRecord(0, 6, 1, 10, 6), result.Records[1]);
}
[Fact]
public void VerticalOnlyOverflowStopsBeforeCompositingGlyph()
{
var rasterizer = new CountingRasterizer(_ => CellMask());
var engine = new RetainedGlyphLayoutEngine(rasterizer);
GlyphTextLayoutResult result = engine.Render(
Image(16, 16),
new GlyphTextLayoutOptions(2, 12, 2, 8, 16, true, Style()),
[NativeRequest(0x82a0, 0x3042)]);
Assert.True(result.StoppedOnVerticalOverflow);
Assert.Equal(0, result.ConsumedGlyphs);
Assert.Empty(result.Records);
Assert.Equal(1, rasterizer.Calls); // metrics are required before the overflow decision
}
[Fact]
public void SimultaneousHorizontalAndVerticalOverflowTakesNativeWrapPath()
{
var engine = new RetainedGlyphLayoutEngine(new CountingRasterizer(_ => CellMask()));
GlyphTextLayoutResult result = engine.Render(
Image(16, 24),
new GlyphTextLayoutOptions(6, 12, 2, 8, 16, true, Style()),
[NativeRequest(0x82a0, 0x3042)]);
Assert.False(result.StoppedOnVerticalOverflow);
Assert.Equal(1, result.ConsumedGlyphs);
Assert.Equal(1, result.WrappedLines);
Assert.Equal(
AdvTextOverflowFlags.Horizontal | AdvTextOverflowFlags.Vertical,
result.ObservedOverflow);
Assert.Equal(new AdvRetainedGlyphRecord(0, 2, 18, 6, 23), Assert.Single(result.Records));
}
[Fact]
public void GlyphCacheIsBoundedAndUsesLruRecency()
{
var inner = new CountingRasterizer(_ => Mask());
var cached = new CachedGlyphMaskRasterizer(inner, capacity: 2);
GlyphRasterRequest a = NativeRequest(0x41, 0x41);
GlyphRasterRequest b = NativeRequest(0x42, 0x42);
GlyphRasterRequest c = NativeRequest(0x43, 0x43);
GlyphMask firstA = cached.Rasterize(a);
cached.Rasterize(b);
Assert.Same(firstA, cached.Rasterize(a));
cached.Rasterize(c);
cached.Rasterize(b);
Assert.Equal(2, cached.Count);
Assert.Equal(1, cached.Hits);
Assert.Equal(4, cached.Misses);
Assert.Equal(4, inner.Calls);
}
[Fact]
public void GenericCacheEvictionCanReleaseBackendFontResources()
{
var evicted = new List<string>();
var cache = new BoundedLruCache<int, string>(2, evicted.Add);
cache.Set(1, "font-1");
cache.Set(2, "font-2");
Assert.True(cache.TryGetValue(1, out _));
cache.Set(3, "font-3");
Assert.Equal(["font-2"], evicted);
Assert.False(cache.TryGetValue(2, out _));
Assert.True(cache.TryGetValue(1, out _));
Assert.True(cache.TryGetValue(3, out _));
}
private static GlyphRasterRequest NativeRequest(ushort cp932, int scalar)
=> new("face", 5, -2, 700, scalar, cp932, GlyphRasterPolicy.NativeCp932Gray4);
private static GlyphMask Mask(byte coverage = 16)
=> new(1, 1, 1, originX: 0, originY: 1,
cellAdvanceX: 1, cellAdvanceY: 0, cellWidth: 1, cellHeight: 1, [coverage]);
private static GlyphMask CellMask()
=> new(1, 1, 1, originX: 0, originY: 5,
cellAdvanceX: 4, cellAdvanceY: 0, cellWidth: 4, cellHeight: 5, [16]);
private static AdvTextStyle Style(int renderMode = 0)
=> new(
PrimaryFontSize: 5,
RubyFontSize: 0,
Bold: true,
TextColor: 0xffffff,
EffectColor: 0,
RenderMode: renderMode,
EffectOffsetX: 0,
EffectOffsetY: 0,
LineSpacing: 1,
FontFace: "face");
private static RgbaImage Image(int width, int height)
=> new(width, height, new byte[checked(width * height * 4)]);
private static (byte R, byte G, byte B, byte A) Pixel(RgbaImage image, int x, int y)
{
int offset = (y * image.Width + x) * 4;
return (
image.Pixels[offset],
image.Pixels[offset + 1],
image.Pixels[offset + 2],
image.Pixels[offset + 3]);
}
}